I contenuti <canvas> normali non sono esposti alle funzionalità del browser come screen reader, indicizzazione, strumenti di traduzione, strumenti di assistenza per l'accessibilità, ricerca nella pagina, stampa e così via. Con HTML in canvas, puoi eseguire il rendering del DOM reale direttamente in un elemento canvas. L'aggiunta dell'attributo content="drawable" a un elemento HTML <canvas> dispone i relativi elementi HTML discendenti, mentre l'attributo drawable contrassegna gli elementi di cui puoi eseguire il rendering nel contesto di rendering del canvas. Puoi utilizzarlo per applicare stili e layout al testo in un canvas, esporre i contenuti del canvas alle funzionalità del browser (come accessibilità, traduzione o ricerca nella pagina) e applicare effetti 2D e 3D all'HTML.
Come implementare
Verifica se HTML-in-Canvas è supportato nel browser:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Aggiungi l'attributo
content="drawable"all'elemento HTML<canvas>.Inserisci i contenuti HTML all'interno dell'elemento
<canvas>e aggiungi l'attributodrawablea ogni elemento che disegni. Un elemento condrawableacquisisce il relativo sottoalbero, ad eccezione dei discendenti nidificati che sono anchedrawable.<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>Scala la griglia del canvas in modo che corrisponda al fattore di scalabilità del dispositivo per evitare sfocature:
const observer = new ResizeObserver(([entry]) => { const dpc = entry.devicePixelContentBoxSize; canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * window.devicePixelRatio); canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * window.devicePixelRatio); }); const supportsDevicePixelContentBox = typeof ResizeObserverEntry !== "undefined" && "devicePixelContentBoxSize" in ResizeObserverEntry.prototype; const options = supportsDevicePixelContentBox ? { box: "device-pixel-content-box" } : {}; observer.observe(canvas, options);Esegui il rendering dei contenuti HTML nel canvas all'interno di un gestore di eventi
canvas.onpaint. Un elemento può essere disegnato solo dopo che il canvas lo ha dipinto: chiamacanvas.requestPaint()per richiedere un eventopaint.
- Nel contesto 2D, utilizza il metodo
drawElementImage. Disegna l'ultimo snapshot di un elementodrawablee il relativo sottoalbero disegnabile, a partire dal riquadro del bordo dell'elemento, prima delle trasformazioni CSS:
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
ctx.drawElementImage(form_element, 0, 0);
};
- Nei contesti WebGL e WebGPU, l'elemento viene copiato in una texture. Dimensiona la texture da
canvas.captureElementImage(element). I valoriwidtheheightsono espressi in pixel della griglia del canvas (backing store), quindi arrotondali per eccesso conMath.ceil().captureElementImage()genera un errore se l'elemento non ha ancora un record di visualizzazione: chiamarequestPaint()e riprova al successivo eventopaint.
// Returns the element's size in canvas grid pixels, or null if the element
// has no paint record yet.
function getElementImageSize(canvas, element) {
let elementImage;
try {
elementImage = canvas.captureElementImage(element);
} catch (err) {
canvas.requestPaint(); // No paint record yet: retry on the next paint event.
return null;
}
const width = Math.ceil(elementImage.width);
const height = Math.ceil(elementImage.height);
elementImage.close();
return width > 0 && height > 0 ? { width, height } : null;
}
- Nel contesto WebGL, prealloca prima il supporto della texture utilizzando il metodo
texImage2D. Gli snippet di questa guida utilizzano un contesto WebGL 2 (canvas.getContext("webgl2")). Alloca lo spazio di archiviazione delle texture chiamandotexImage2D()connullanziché con i dati dei pixel, in modo da riservare la memoria GPU delle dimensioni e del formato specificati senza caricare alcun pixel. Segui queste regole:- Ridimensiona la texture da
captureElementImage(), come mostrato sopra. - Alloca il livello 0 solo al primo caricamento o quando le dimensioni cambiano, perché la riallocazione cancella la texture. Utilizza lo spazio di archiviazione modificabile: lo spazio di archiviazione
texStorage2D()immutabile non può essere ridimensionato quando l'elemento cambia dimensione.
- Ridimensiona la texture da
// Binds the texture, and allocates its storage on the first upload, and when
// the element size changes. `state` stores the allocated size.
function allocateElementTexture(gl, texture, state, width, height) {
gl.bindTexture(gl.TEXTURE_2D, texture);
if (state.width === width && state.height === height) return;
gl.texImage2D(
gl.TEXTURE_2D,
0, // Mipmap level
gl.RGBA8, // Internal format: 8 bits per RGBA channel
width, // Size in canvas grid pixels
height,
0, // Border: must be 0
gl.RGBA, // Format and type that match RGBA8
gl.UNSIGNED_BYTE,
null // null reserves GPU memory without uploading pixels
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
state.width = width;
state.height = height;
}
const texture = gl.createTexture();
const textureState = { width: 0, height: 0 };
- Poi, nel contesto WebGL, esegui il rendering dei contenuti HTML nella texture utilizzando il metodo
texElementSubImage2D. Segui queste regole:- Passa
{ width, height }nella configurazione. In questo modo, la copia riempie la texture allocata indipendentemente dalla scalabilità dell'area di lavoro. - Riassegna la texture se le dimensioni sono cambiate, come mostrato sopra.
- Passa
// Uploads the element into the texture. Returns false if the element has no
// paint record yet. In that case, it's uploaded on the next paint event.
function uploadElementWebGL(gl, canvas, texture, element, state) {
const size = getElementImageSize(canvas, element);
if (!size) return false;
const { width, height } = size;
// Bind the texture, and reallocate it if the size changed (see above).
allocateElementTexture(gl, texture, state, width, height);
gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, element, { width, height });
return true;
}
canvas.onpaint = () => {
try {
uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
} catch (err) {
console.error('texElementSubImage2D copy failed:', err);
}
};
- Nel contesto WebGPU, utilizza il metodo
drawElementImageToTexture. Segui queste regole:- Crea la texture con l'utilizzo di
COPY_DSTeRENDER_ATTACHMENTe aggiungiTEXTURE_BINDINGper campionarla negli shader. Un utilizzo errato viene segnalato come errore di convalida WebGPU, non come eccezione. - Passa le dimensioni della texture come
size. Senzasize, un elemento più grande viene ritagliato in base alla texture. Consize, l'elemento viene ridimensionato per riempirlo. - Le texture WebGPU non possono essere ridimensionate. Ricrea la texture quando le dimensioni dell'elemento cambiano e ricrea i gruppi di associazione che vi fanno riferimento.
- Crea la texture con l'utilizzo di
// WebGPU textures can't be resized. Create the texture once, and recreate it
// when the element size changes.
function ensureElementTexture(device, state, width, height) {
if (state.texture?.width === width && state.texture?.height === height) {
return state.texture;
}
state.texture?.destroy();
state.texture = device.createTexture({
size: { width, height },
format: "rgba8unorm",
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT,
});
return state.texture; // Recreate the bind groups that used the previous texture.
}
const textureState = { texture: null };
canvas.onpaint = () => {
const size = getElementImageSize(canvas, uiElement);
if (!size) return;
const previousTexture = textureState.texture;
const texture = ensureElementTexture(device, textureState, size.width, size.height);
if (texture !== previousTexture) {
// New texture: create, or rebuild, the bind groups that reference it.
}
try {
device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
} catch (err) {
console.error('drawElementImageToTexture copy failed:', err);
}
};
Quando utilizzi un loop requestAnimationFrame per eseguire il rendering della scena, chiama canvas.requestPaint() all'interno del loop per assicurarti che il rendering dei contenuti HTML venga eseguito nel canvas. event.changedElements elenca gli elementi il cui rendering è cambiato. Assicurati di ridisegnare l'elemento o di ricaricare la sua texture solo se è stato eseguito un aggiornamento degli elementi HTML discendenti:
function render() {
// Request to update the canvas
canvas.requestPaint();
requestAnimationFrame(render);
}
requestAnimationFrame(render);
canvas.onpaint = (event) => {
if (event.changedElements && event.changedElements.length > 0) {
// Redraw the element, or update its texture, as shown above
}
// In WebGL and WebGPU, render the scene, and sync the element geometry as shown in step 6
};
- Sincronizza la geometria dell'elemento.
Il browser deve sapere dove viene disegnato l'elemento HTML, in modo che la sua posizione DOM corrisponda ai pixel disegnati. L'hit test, ad esempio per i clic e la selezione del testo, e l'accessibilità utilizzano questa geometria: un elemento drawable la cui geometria non è aggiornata viene comunque esposto all'albero di accessibilità, ma senza informazioni sulla geometria. Un canvas esegue l'hit test solo degli elementi disegnati con il metodo 2D drawElementImage() o registrati con updateElementGeometry().
Per il caso del contesto 2D, il browser sincronizza automaticamente l'elemento.
drawElementImage()allinea i limiti dell'hit test del DOM e gli anelli di messa a fuoco dello screen reader con le coordinate disegnate, quindi non è necessario impostarestyle.transform. Per gestire la geometria manualmente, ad esempio quando disegni lo stesso elemento più di una volta, passa{ preserveElementGeometry: true }adrawElementImage()e applica le trasformazioni concanvas.updateElementGeometry().Nel caso 3D con WebGL o WebGPU, il canvas non può sapere dove viene disegnato l'elemento HTML, quindi devi indicarglielo. Calcola un
DOMMatrixche mappa il bordo dell'elemento, in pixel CSS, al canvas, in pixel CSS. Il browser deve mappare lo spazio delle coordinate 3D nello spazio delle coordinate CSS utilizzando una trasformazione dell'area visibile. Per facilitare questa operazione, segui questi passaggi:- Normalizza l'elemento HTML. Le dimensioni degli elementi HTML sono espresse in pixel (ad esempio, 200 px di larghezza). WebGL, tuttavia, di solito tratta gli oggetti come "quadrati unitari", ad esempio con un intervallo compreso tra -0,5 e 0,5. Se non normalizzi, il pulsante da 200 px apparirà 200 volte più grande. Questo passaggio inverte anche l'asse Y, perché in CSS il valore positivo è verso il basso, mentre in WebGL è verso l'alto.
- Converti la matrice MVP WebGL in una matrice DOM.
- Mappa all'area visibile del canvas. Questo passaggio è la fase di "ridimensionamento": estende nuovamente la matematica dello spazio unitario per adattarla alle dimensioni in pixel CSS dell'elemento
<canvas>sullo schermo e capovolge l'asse Y. - Calcola la trasformazione finale. Moltiplica le matrici nell'ordine: Viewport * MVP * Normalization. La loro combinazione in un'unica trasformazione finale produce una "mappa" che indica al browser esattamente dove deve trovarsi l'elemento HTML per allinearsi al disegno 3D. Il browser esegue la divisione prospettica.
// Maps the element's border box (CSS pixels) to the canvas (CSS pixels), for a // quad that spans -0.5 to 0.5 in model space and is drawn with the `mvp` matrix. function computeCanvasTransform(canvas, element, mvp) { const width = element.offsetWidth; const height = element.offsetHeight; // 1. Normalize the HTML element (CSS pixels -> WebGL Model Space) const toGLModel = new DOMMatrix() // Scale pixels to 1 unit, flip Y (as in CSS it points down, and in WebGL it points up) .scale(1 / width, -1 / height, 1 / height) // Center the origin: (0,0) becomes (-width/2, -height/2) before scaling .translate(-width / 2, -height / 2); // 2. Convert WebGL MVP Matrix to DOM Matrix const mvpDOM = new DOMMatrix(Array.from(mvp)); // 3. Map to the canvas viewport, in CSS pixels const clipToCanvasViewport = new DOMMatrix() // Move center (0,0) to center of canvas .translate(canvas.clientWidth / 2, canvas.clientHeight / 2) // Scale normalized clip (-1..1) to viewport size, and flip Y back .scale(canvas.clientWidth / 2, -canvas.clientHeight / 2, canvas.clientHeight / 2); // 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square) return clipToCanvasViewport.multiply(mvpDOM).multiply(toGLModel); }
Poi, passa la trasformazione a canvas.updateElementGeometry() come canvasTransform. In questo modo, l'elemento HTML viene posizionato direttamente sopra i pixel visualizzati. In questo modo, quando un utente fa clic su un pulsante o seleziona un testo, interagisce effettivamente con l'elemento HTML reale. Il browser utilizza la trasformazione anche per i limiti di accessibilità dell'elemento, ad esempio per gli anelli di messa a fuoco dello screen reader:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
Utilizza queste opzioni per controllare l'hit test:
- Ogni chiamata
updateElementGeometry()sposta l'elemento in cima all'ordine di hit test. Passa{ preserveHitTestOrder: true }per mantenere la sua posizione. - Chiama
canvas.clearElementGeometry(element)per rimuovere un elemento dall'hit test, ad esempio quando lo nascondi.
Esempio di codice
Canvas 2D
<canvas id="canvas" content="drawable" style="width: 400px; height: 200px;">
<div id="ui-element" drawable>
<p>
This text is rendered inside the canvas but is present in the DOM tree.
</p>
<input type="email" name="email" placeholder="enter your email" />
<button type="button">Submit</button>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const uiElement = document.getElementById("ui-element");
canvas.onpaint = () => {
ctx.reset();
// Draw the HTML element at x:0, y:0. The browser syncs the DOM location
// with the drawn location.
ctx.drawElementImage(uiElement, 0, 0);
};
// Handle resizing to match device pixels
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
canvas.width = dpc
? dpc[0].inlineSize
: Math.round(entry.contentRect.width * window.devicePixelRatio);
canvas.height = dpc
? dpc[0].blockSize
: Math.round(entry.contentRect.height * window.devicePixelRatio);
canvas.requestPaint();
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== "undefined" &&
"devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox
? { box: "device-pixel-content-box" }
: {};
observer.observe(canvas, options);
</script>
Canvas WebGL
<canvas id="canvas" content="drawable" style="width: 400px; height: 400px;">
<div id="ui-element" drawable>
<p>WebGL UI Element</p>
<button>Action</button>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const gl = canvas.getContext("webgl2");
const uiElement = document.getElementById("ui-element");
// getElementImageSize(), allocateElementTexture(), and uploadElementWebGL()
// are defined in step 5.
// computeCanvasTransform() is defined in step 6.
// Setup WebGL texture...
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
const textureState = { width: 0, height: 0 };
canvas.onpaint = () => {
// 1. Update texture with HTML content
try {
uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
} catch (err) {
console.error('texElementSubImage2D copy failed:', err);
}
// ... Render your 3D scene here, calculating htmlElementMVP matrix ...
// 2. Sync DOM position, hit testing, and accessibility bounds with 3D scene
canvas.updateElementGeometry(uiElement, {
canvasTransform: computeCanvasTransform(canvas, uiElement, htmlElementMVP),
});
};
</script>
Canvas WebGPU
<canvas id="canvas" content="drawable" style="width: 400px; height: 400px;">
<div id="ui-element" drawable>
<p>WebGPU UI Element</p>
</div>
</canvas>
<script>
const canvas = document.getElementById("canvas");
const context = canvas.getContext("webgpu");
const uiElement = document.getElementById("ui-element");
// getElementImageSize() and ensureElementTexture() are defined in step 5.
// computeCanvasTransform() is defined in step 6.
// Setup WebGPU...
// const device = ...
const textureState = { texture: null };
canvas.onpaint = () => {
// 1. Copy HTML content to a texture that matches the element size
const size = getElementImageSize(canvas, uiElement);
if (size) {
const previousTexture = textureState.texture;
const texture = ensureElementTexture(device, textureState, size.width, size.height);
if (texture !== previousTexture) {
// New texture: create, or rebuild, the bind groups that reference it.
}
try {
device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
} catch (err) {
console.error('drawElementImageToTexture copy failed:', err);
}
}
// ... Render your 3D scene here, calculating htmlElementMVP matrix ...
// 2. Sync DOM position, hit testing, and accessibility bounds (same matrix math as WebGL)
canvas.updateElementGeometry(uiElement, {
canvasTransform: computeCanvasTransform(canvas, uiElement, htmlElementMVP),
});
};
</script>
Best practice
- OBBLIGATORIO: verifica il supporto del browser per l'API HTML-in-Canvas prima di utilizzarla.
- OBBLIGATORIO: aggiungi sempre l'attributo
content="drawable"all'elemento<canvas>. - OBBLIGATORIO: aggiungi l'attributo
drawablea ogni elemento che disegni, inclusi gli elementi secondari diretti del canvas. - OBBLIGATORIO: utilizza un gestore di eventi
onpaintper eseguire il rendering dei contenuti HTML nel canvas. - OBBLIGATORIO: utilizza il metodo
drawElementImagein 2D odrawElementImageToTexturein WebGPU per eseguire il rendering dei contenuti HTML nel canvas. - OBBLIGATORIO: in WebGL, prealloca il buffer delle texture con
texImage2D()una volta (quando viene inizializzato o quando cambiano le dimensioni dell'elemento). Riassegna la texture solo quando le dimensioni cambiano e impostaTEXTURE_MIN_FILTERsuLINEAR. - OBBLIGATORIO: in WebGL, utilizza il metodo
texElementSubImage2Dper eseguire il rendering dei contenuti HTML in una texture preallocata contexImage2D(), con dimensioni dacaptureElementImage(). - OBBLIGATORIO: in WebGPU, ridimensiona la texture da
captureElementImage()e ricreala solo quando le dimensioni cambiano. Crea la texture con l'utilizzo diCOPY_DSTeRENDER_ATTACHMENTe passa la dimensione della texture comesize. - OBBLIGATORIO: sincronizza la geometria dell'elemento HTML con i contenuti visualizzati. In 2D,
drawElementImagelo fa automaticamente. In WebGL e WebGPU, chiamacanvas.updateElementGeometry(element, { canvasTransform }). - OBBLIGATORIO: utilizza
ResizeObserverper osservare le dimensioni dello schermo e aggiornare le dimensioni del canvas in modo che corrispondano ai pixel del dispositivo. - NON incorporare contenuti multiorigine in un canvas, poiché non sono supportati.
- NON inizializzare
ResizeObserverall'interno del gestore di eventionpaint, in quanto ciò potrebbe causare perdite di memoria.
Strategie di riserva
L'API HTML-in-Canvas non è attualmente supportata in tutti i browser moderni, pertanto in genere è necessaria una strategia di riserva.
Tuttavia, dati i vantaggi in termini di prestazioni di questa API, è consigliabile utilizzare HTML-in-Canvas se il browser lo supporta.
La strategia di riserva dipende dal caso d'uso. Ad esempio, per un contenuto HTML interattivo in un canvas, se l'HTML nel canvas non è supportato, posiziona il contenuto HTML sopra il canvas utilizzando CSS.
Polyfill HTML-in-Canvas
Utilizza il seguente script polyfill per simulare l'API HTML-in-Canvas nei browser che non la supportano.
Installa o incorpora la libreria:
# Install npm install three-html-render# Embed <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>Esegui il metodo
installHtmlInCanvasPolyfill()per tradurre l'HTML in Canvas.