HTML-in-Canvas: технические концепции

HTML-in-Canvas API позволяет отрисовывать интерактивные и доступные поддеревья DOM непосредственно в контекстах холста 2D, WebGL или WebGPU.

В этом руководстве описаны технические концепции 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):

Диаграмма из четырех панелей, на которой сравниваются сетки пикселей для изогнутого края: 1. Несогласованные сетки CSS (360 на 180), холста (300 на 150) и устройства (540 на 270). 2. Кривая сглажена на грубой сетке холста 300 на 150. 3. Это растровое изображение интерполируется в сетку устройства размером 540 на 270, сглаживается во второй раз и размывает край. 4. Холст размером 540 на 270 пикселей, соответствующий сетке устройства в масштабе 1:1, с однократным сглаживанием для получения четких краев.
Как несовпадение пиксельных сеток CSS, холста и устройства приводит к двойному сглаживанию (панели 1–3) по сравнению с сеткой холста и устройства 1:1 (панель 4).

Чтобы изображение было четким, сетка холста должна соответствовать количеству пикселей устройства, которые он покрывает (в предыдущем примере – 540 × 270).

Параллельное сравнение HTML-карточки с текстом &quot;Hello from the DOM! Эта карточка представляет собой HTML-код, отрисованный на холсте.' Слева: нарисовано на холсте по умолчанию размером 300 на 150 пикселей, выглядит размыто. Справа: нарисовано на холсте размером 800 на 400 пикселей, размер которого задан с помощью ResizeObserver. Изображение выглядит четким.
Тот же холст размером 400 x 200 пикселей CSS на экране с DPR 1,5. Слева: сетка холста размером 400 x 200. Справа: сетка 600 x 300, скорректированная с учетом DPR.

Как задать размер сетки холста с помощью 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 не поддерживается, вы можете выбрать один из следующих вариантов в зависимости от ваших целей:

Дальнейшие действия