HTML-in-Canvas API позволяет отрисовывать интерактивные и доступные поддеревья DOM непосредственно в контекстах холста 2D, WebGL или WebGPU.
В этом руководстве описаны технические концепции HTML-in-Canvas, которые являются общими для всех контекстов холста:
- Подготовка холста для отрисовки HTML
- Что такое поддерево
drawable - Что такое событие
paint - Как изменить геометрию элемента
- Специальные возможности HTML-in-Canvas
- Используйте резервные стратегии
Подготовьте холст для отрисовки HTML
Чтобы элементы HTML отображались внутри <canvas>, настройте следующие два атрибута:
- Задайте
content="drawable"для<canvas>. Это позволит элементу canvas размещать дочерние узлы DOM, а не обрабатывать их только как скрытый контент. - Задайте
drawableдля целевых элементов. Это пометит элемент как независимый корень отрисовки, подходящий для отрисовки холста.
<canvas id="mainCanvas" width="600" height="400" content="drawable">
<div id="parentElement" class="card" drawable>
<h2>Container Title</h2>
<p>Descriptive text inside the parent element.</p>
<!-- Nested drawable: excluded from parentElement and drawn independently -->
<button id="childElement" class="btn" drawable>Action Button</button>
</div>
</canvas>
Понимание поддерева drawable
При подготовке HTML-кода для отрисовки на холсте необходимо решить, какие элементы должны быть нарисованы независимо. Каждый элемент drawable представляет собой независимый корень отрисовки и его поддерево.
Поддерево отрисовки состоит из элемента с атрибутом drawable и всех его потомков, за исключением вложенных потомков, которые также содержат drawable, например:
<canvas content="drawable">
<!-- Subtree A (Renders parent container and text) -->
<div id="parentElement" drawable>
<h2>Parent Card Heading</h2>
<p>Card body text content.</p>
<!-- Subtree B (Excluded from Subtree A and drawn separately) -->
<button id="nestedChildElement" drawable>
Clickable Button
</button>
</div>
</canvas>
| Элемент | Граница отрисовки | Поведение |
|---|---|---|
#parentElement (поддерево A) |
Фоновая панель, заголовок и абзац | Отображается при вызове drawElementImage(parentElement, ...) |
#nestedChildElement (поддерево B) |
Поверхность и текст кнопки | Исключено из поддерева #parentElement; отображается в отдельном вызове drawElementImage(nestedChildElement, ...) |
drawable вложена в карточку drawable, и каждая из них отрисовывается собственным вызовом drawElementImage().Вложение графических элементов позволяет сохранять семантические отношения HTML, предоставляя вам свободу независимо накладывать, позиционировать или анимировать дочерние элементы на холсте.
Что такое событие paint
HTML-in-Canvas API добавляет событие paint. Браузер автоматически отправляет событие paint в <canvas>, когда меняется макет, текст или стили CSS в любом из дочерних элементов drawable.
canvas.addEventListener("paint", (event) => {
// Clear the viewport
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Redraw elements individually
ctx.drawElementImage(parentElement, 20, 20);
ctx.drawElementImage(childElement, 40, 90);
});
Событие paint отправляет экземпляр CanvasPaintEvent, который расширяет стандартный DOM Event и имеет свойство changedElements, содержащее только потомков drawable, которые визуально изменились (например, изменения стиля, сдвиги макета, редактирование текста или изменения состояния фокуса) с момента последней отрисовки.
canvas.addEventListener("paint", (event) => {
// Array of changed elements
for (const el of event.changedElements) {
console.log(`Element #${el.id} visually changed and needs repainting`);
}
});
Жизненный цикл фрейма
Команды рисования на холсте, выполненные в событии paint, появляются в текущем обновлении рендеринга, но изменения DOM, внесенные в событии paint, не отображаются до следующего кадра:
canvas.addEventListener("paint", () => {
// Renders in the CURRENT rendering update:
ctx.drawElementImage(parentElement, 20, 20);
// Modifying DOM styles inside paint schedules a new layout cycle;
// changes will appear in the NEXT frame:
parentElement.style.backgroundColor = "#e0f2fe";
});
Вложенные холсты
Если на странице есть вложенные элементы <canvas content="drawable">, браузер отправляет события paint в обратном порядке дерева DOM (потомки активируются раньше предков). Это гарантирует, что дочерние элементы холста будут отрисованы до того, как родительские элементы холста объединят их.
Предположим, что в следующем примере внутренний элемент nestedElement должен визуально измениться (например, цвет его фона CSS меняется, когда пользователь наводит на него указатель). Сначала на холсте nestedCanvas активируется событие paint. Его прослушиватель очищает дочерний буфер и вызывает ctx.drawElementImage(nestedElement, ...) для отрисовки обновленного состояния. Затем на странице parentCanvas активируется событие paint. Его прослушиватель рисует wrapperElement (который содержит уже обновленный холст nestedCanvas) в сцене.
<!-- Outer parent canvas -->
<canvas id="parentCanvas" width="800" height="600" content="drawable">
<!-- Wrapper container for nested content -->
<div id="wrapperElement" drawable>
<!-- Nested child canvas -->
<canvas id="nestedCanvas" width="300" height="200" content="drawable">
<div id="nestedElement" class="badge" drawable>
Inner Child Content
</div>
</canvas>
</div>
</canvas>
<script>
const parentCanvas = document.getElementById("parentCanvas");
const parentCtx = parentCanvas.getContext("2d");
const nestedCanvas = document.getElementById("nestedCanvas");
const nestedCtx = nestedCanvas.getContext("2d");
const nestedElement = document.getElementById("nestedElement");
const wrapperElement = document.getElementById("wrapperElement");
// ============================================================================
// 1. Child Canvas Listener (Fires FIRST)
// ============================================================================
nestedCanvas.addEventListener("paint", (event) => {
// Clear and redraw the child canvas with the updated inner element
nestedCtx.clearRect(0, 0, nestedCanvas.width, nestedCanvas.height);
nestedCtx.drawElementImage(nestedElement, 10, 10);
console.log("1. Child nested canvas rendered fresh pixels.");
});
// ============================================================================
// 2. Parent Canvas Listener (Fires SECOND)
// ============================================================================
parentCanvas.addEventListener("paint", (event) => {
// Clear and redraw the parent canvas scene
parentCtx.clearRect(0, 0, parentCanvas.width, parentCanvas.height);
// Draws the wrapper container (including the freshly painted nestedCanvas)
parentCtx.drawElementImage(wrapperElement, 50, 50);
console.log("2. Parent canvas composited the complete scene.");
});
</script>
<canvas content="drawable"> вложены, события отрисовки запускаются в обратном порядке: сначала отрисовывается вложенный холст, чтобы родительский холст мог нарисовать свои обновленные пиксели.Активировать событие paint
Вызовите requestPaint(), чтобы запланировать одно событие paint в следующем фрейме, даже если текст или стили DOM не изменились. Это можно использовать для рендеринга интерактивных сцен с движущимися элементами, например:
let animatedX = 0;
canvas.addEventListener("paint", () => {
animatedX += 1.5; // Move the element horizontally
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawElementImage(staticElement, 20, 20);
ctx.drawElementImage(movingElement, animatedX, 200);
// Request canvas paint on each rendering update for smooth animation
canvas.requestPaint();
});
canvas.requestPaint();
Как изменить геометрию элемента
Когда вы рисуете элемент HTML на холсте, код рисования определяет, где появляется элемент и насколько он велик. В отличие от обычного HTML-контента, положение и размер элемента на экране, его геометрию, нельзя определить только по его вычисленному стилю. Браузеру необходима геометрия элемента, чтобы:
- Отправлять события ввода, такие как клики, нажатия и движения указателя, нужному элементу. Этот процесс называется проверкой нажатия.
- Возвращать точные результаты по запросам, связанным с макетом, например
getBoundingClientRect(). - Передавать информацию о границах элемента технологиям специальных возможностей, например экранным лупам.
В 2D-режиме drawElementImage() по умолчанию автоматически обновляет геометрию. Передаваемые аргументы вместе с вычисленным стилем элемента и текущим преобразованием контекста позволяют определить, где появится элемент.
В контекстах WebGL и WebGPU браузер не может определить геометрию. Элемент HTML копируется в текстуру, а затем программа шейдера может произвольным образом сопоставить эту текстуру со сценой. Чтобы сообщить браузеру, где появляется элемент, передайте преобразование в updateElementGeometry().
updateElementGeometry(), браузер больше не знает, где находятся элементы HTML на 3D-карте. Карточка продолжает вращаться, но клики по-прежнему попадают туда, где она была.Чтобы обновить геометрию, передайте методу пространственное преобразование. Ниже рассказывается, как создавать преобразования для разных контекстов.
Управление порядком наложения при проверке нажатия
По умолчанию каждый раз, когда геометрия элемента обновляется, браузер помещает его в верхнюю часть стека взаимодействия (последний нарисованный элемент – самая верхняя цель клика). Вы можете управлять этим поведением с помощью preserveHitTestOrder:
// Updates the backdrop without stealing clicks from foreground elements
canvas.updateElementGeometry(backgroundElement, {
canvasTransform: customTransform,
preserveHitTestOrder: true
});
| Option | Поведение | Пример использования |
|---|---|---|
preserveHitTestOrder: false (по умолчанию) |
Перемещает элемент в верхнюю часть стека проверки нажатия. | Элементы переднего плана, на которые пользователи должны нажимать в первую очередь. |
preserveHitTestOrder: true |
Элемент остается на текущем уровне приоритета. | Элементы фона, которые нужно обновить после элементов переднего плана. |
Сбросить геометрию
Чтобы отменить отрисовку элемента, не удаляя его из DOM, вызовите canvas.clearElementGeometry(). Это сбрасывает преобразование холста элемента и помечает его как находящийся за пределами экрана в дереве специальных возможностей:
// Remove from canvas hit-testing and mark as offscreen
canvas.clearElementGeometry(childElement);
Размеры и единицы измерения
Когда вы рисуете HTML на холсте, браузер работает сразу в трех системах координат: пиксели CSS, пиксели устройства и пиксели сетки холста.
Пиксели CSS используются для измерения и позиционирования контента в макете. Затем браузер масштабирует их на devicePixelRatio, чтобы растрировать страницу в физические пиксели устройства. Поскольку пиксели CSS являются логической единицей, позиции и размеры часто оказываются долями пикселя устройства (например, 10,5 пикселей CSS × DPR 1,5 = 15,75 пикселей устройства). Экран может окрашивать только целые пиксели, поэтому браузеру приходится решать, что делать с тем, что попадает между ними. Это делается двумя способами:
- Привязка. Границы блоков, рамки и смещения слоев округляются до ближайшего целого пикселя устройства, чтобы они оставались четкими.
- Сглаживание. Для глифов текста, кривых и преобразованных краев частичное покрытие смешивается с соседними пикселями, чтобы они выглядели гладкими и находились в правильном месте.
У элемента <canvas> есть собственная сетка пикселей, которая задается атрибутами width и height и не зависит от пикселей CSS и пикселей устройства. Чтобы отобразить его, браузер повторно дискретизирует растровое изображение холста в физическую сетку пикселей, смешивая соседние пиксели холста, чтобы заполнить каждый пиксель устройства. Чтобы узнать, как браузер сопоставляет пиксели CSS, холста и устройства, прочитайте статью Идеальный рендеринг с помощью devicePixelContentBox.
Все элементы HTML drawable размещаются в пикселях CSS. Поскольку контент, отрисованный на холсте, может проходить два преобразования (из пикселей CSS в сетку холста, а затем из сетки холста в физические пиксели), каждый шаг, который не выравнивается точно, добавляет еще один раунд смешивания, и результат может выглядеть размытым. Чтобы избежать этого, сделайте так, чтобы сетка холста соответствовала сетке физических пикселей. Это позволяет:
- Согласованная отрисовка. Если холст сопоставляется с физическими пикселями в соотношении 1:1, преобразование пикселей CSS в пиксели холста дает тот же результат, что и преобразование браузером пикселей CSS в физические пиксели для обычного HTML.
- Нет дополнительного смешивания. Если холст сопоставляется с физическими пикселями в соотношении 1:1, преобразование в пиксели устройства не добавляет размытия.
На изображении ниже показано, как преобразование пикселей приводит к размытию контента. Три сетки не выровнены: элемент холста имеет размер 360 × 180 пикселей CSS, а размер холста составляет 300 × 150 пикселей сетки холста. При соотношении логических и физических пикселей устройства, равном 1,5, холст занимает 540 × 270 физических пикселей. Один пиксель CSS охватывает 1,5 пикселя устройства, а один пиксель холста охватывает 1,2 пикселя CSS или 1,8 пикселя устройства (панель 1). Когда круг нарисован, его положение и радиус задаются в пикселях холста, а не в пикселях CSS, поэтому браузер рисует его на растровом изображении холста размером 300 × 150 и сглаживает край (панель 2). Чтобы показать холст, браузер растягивает эту битовую карту на 540 × 270 пикселей устройства, поэтому браузеру приходится выполнять интерполяцию: каждый пиксель устройства принимает взвешенную смесь ближайших пикселей холста, которые уже содержат сглаженный край. Край смешивается во второй раз и распространяется на несколько пикселей устройства, поэтому круг выглядит размытым (панель 3). Этого можно избежать, если сетка холста будет соответствовать пикселям устройства 1:1. Затем браузер сгладит край только один раз, непосредственно на сетке устройства, и отобразит растровое изображение без повторной выборки, чтобы край оставался четким (панель 4):
Чтобы изображение было четким, сетка холста должна соответствовать количеству пикселей устройства, которые он покрывает (в предыдущем примере – 540 × 270).
Как задать размер сетки холста с помощью ResizeObserver
Размер сетки зависит от устройства и может меняться при изменении макета, масштабировании страницы или перемещении окна на другой экран. Поэтому вместо того, чтобы жестко кодировать размер сетки, позвольте браузеру сказать вам, каким он должен быть.
Чтобы сетка макета соответствовала макету на экране, всегда задавайте для нее размер ResizeObserver и настраивайте сетку макета на devicePixelContentBoxSize:
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);
Преобразование пикселей CSS в координаты сетки холста
Обработчик paint часто сочетает измерения DOM с вызовами отрисовки, например чтобы расположить элемент на основе его размера. В них используются разные единицы измерения:
- Пиксели холста описывают сетку холста, размер которой определяется параметрами
canvas.widthиcanvas.height. Например, параметрыdx,dy,dwidthиdheightметодов рисованияdrawElementImage()или другие параметры API холста, такие какfillRect()иtranslate(). - CSS-пиксели описывают страницу. Размеры DOM, такие как
offsetWidthиclientWidth, и координаты событий указателя, такие какoffsetX, указываются в пикселях CSS.
Ниже приведены единицы измерения, используемые различными параметрами API.
| Значение | Единицы |
|---|---|
canvas.width и canvas.height |
Пиксели сетки холста |
Параметры 2D-чертежа, включая dx, dy, dwidth и dheight для drawElementImage() |
Пиксели сетки холста |
"width" и "height" с разбивкой по показателю "canvas.getElementImageDefaultSize()" |
Пиксели сетки холста |
Исходные прямоугольники: sx, sy, swidth и sheight из drawElementImage() и texElementSubImage2D(); sourceX, sourceY, sourceWidth и sourceHeight из drawElementImageToTexture() |
Пиксели CSS |
Размеры текстур: width и height вариантов из texElementSubImage2D(); size из drawElementImageToTexture() |
Пиксели текстуры. По умолчанию размер элемента в пикселях сетки холста, округленный в большую сторону. |
Геометрия элемента: getElementTransform(), а также параметры canvasTransform и canvasClip для updateElementGeometry() |
Пиксели CSS |
Измерения DOM, такие как offsetWidth, clientWidth, getBoundingClientRect(), координаты событий указателя и contentBoxSize записи наблюдателя за изменением размера. |
Пиксели CSS |
devicePixelContentBoxSize записи наблюдателя за изменением размера |
Пиксели устройства |
Если вы используете неправильные единицы измерения, размер HTML-контента, отрисованного на холсте, может не соответствовать вашим ожиданиям. При отрисовке HTML на холсте необходимо преобразовать значение пикселей CSS в пиксели сетки холста. Для этого рассчитайте соотношение между размером сетки холста и размером холста в CSS: canvas.width / cssContentWidth по горизонтали и canvas.height / cssContentHeight по вертикали. Для расчета соотношения сторон удобно использовать наблюдатель за изменением размера:
// The number of canvas grid pixels per CSS pixel
let scaleX = 1;
let scaleY = 1;
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
const cssContentWidth = entry.contentRect.width;
const cssContentHeight = entry.contentRect.height;
// Best practice: make the canvas grid match the physical pixel grid
if (dpc) {
canvas.width = dpc[0].inlineSize;
canvas.height = dpc[0].blockSize;
} else {
canvas.width = Math.round(cssContentWidth * window.devicePixelRatio);
canvas.height = Math.round(cssContentHeight * window.devicePixelRatio);
}
scaleX = canvas.width / cssContentWidth;
scaleY = canvas.height / cssContentHeight;
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== 'undefined' &&
'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox ? { box: 'device-pixel-content-box' } : {};
observer.observe(canvas, options);
Затем умножьте значения пикселей CSS на scaleX и scaleY, прежде чем передавать их в метод рисования. Например, чтобы разместить элемент по центру макета, используйте следующий код:
const ctx = canvas.getContext('2d');
const card = document.getElementById('card');
canvas.onpaint = () => {
ctx.reset();
// offsetWidth and offsetHeight are in CSS pixels.
// Convert them to canvas grid coordinates.
const width = card.offsetWidth * scaleX;
const height = card.offsetHeight * scaleY;
const x = (canvas.width - width) / 2;
const y = (canvas.height - height) / 2;
// drawElementImage() takes canvas grid coordinates.
ctx.drawElementImage(card, x, y);
};
Наконец, поскольку drawElementImage() рисует рамку элемента, используйте box-sizing: border-box, чтобы включить отступы и рамку в width и height карточки. Тогда карточка будет нарисована в заданном вами размере:
#card {
/* Make width and height include padding and border */
box-sizing: border-box;
width: 200px;
padding: 16px;
}
Специальные возможности HTML-in-Canvas
Как правило, HTML-контент элемента <canvas content="drawable"> доступен для специальных возможностей так же, как и обычный DOM. Это означает, что он представлен в дереве специальных возможностей, если только он не скрыт явным образом с помощью таких атрибутов, как aria-hidden="true" или inert. Однако есть одно важное исключение: элемент drawable, отрисованная геометрия которого ещё не установлена или не обновлена.
Элемент drawable внутри элемента <canvas content="drawable"> не имеет отрисованной геометрии, если:
- Он ещё не был отрисован с помощью
drawElementImage(), который по умолчанию автоматически обновляет геометрию. - Его геометрия не была явно обновлена с помощью
updateElementGeometry().
Если у элемента drawable нет отрисованной геометрии, то этот drawable элемент и его поддерево drawable передаются в дерево специальных возможностей без информации о геометрии. Это полезно, например, чтобы программы чтения с экрана могли получить доступ к семантическому контенту, а визуальные вспомогательные инструменты (такие как лупы) могли отфильтровать невычерченные элементы.
В следующем примере сохраняется семантика рисунка, изображения и подписи, а изображение и рисунок отрисовываются отдельно:
<canvas id="canvas" width="400" height="300" content="drawable">
<figure id="figure">
<img drawable id="image" src="..." alt="image alt text" />
<figcaption drawable id="caption">A caption</figcaption>
</figure>
</canvas>
<script>
const ctx = canvas.getContext('2d');
canvas.onpaint = () => {
ctx.reset();
// Note: Because `preserveElementGeometry` defaults to false, the
// `drawElementImage` call automatically updates geometry, ensuring
// the image and caption have geometry information for accessibility.
ctx.drawElementImage(image, 0, 0);
ctx.drawElementImage(caption, 0, 200);
};
</script>
Резервные стратегии
HTML-in-Canvas API поставляется в Chrome 157 и может поддерживаться не во всех браузерах. Чтобы ваше решение работало без сбоев для всех, проверьте, поддерживает ли браузер API, и при необходимости вернитесь к альтернативному решению:
export function supportsHtmlInCanvas() {
if (typeof document === 'undefined') return false;
const canvas = document.createElement('canvas');
canvas.setAttribute('content', 'drawable');
return canvas.content === 'drawable';
}
Если API не поддерживается, вы можете выбрать один из следующих вариантов в зависимости от ваших целей:
- Альтернативный интерфейс
- Плавающие оверлеи HTML
- Библиотека three-html-render
- html2canvas
- dom-to-image
Дальнейшие действия
- Подробнее о том, как отрисовывать HTML в контексте 2D…
- Узнайте, как отрисовывать HTML на холсте WebGL.
- Узнайте, как отрисовывать HTML на холсте WebGPU.
- Подробнее о резервных стратегиях…
- Чтобы поделиться отзывом, создайте проблему на GitHub.
- Посмотрите коллекцию демонстрационных материалов по HTML-in-Canvas, созданную сообществом.