Nội dung <canvas> thông thường không được hiển thị cho các tính năng của trình duyệt như trình đọc màn hình, công cụ lập chỉ mục, công cụ dịch, công cụ hỗ trợ tiếp cận, tính năng tìm trong trang, in, v.v. Với HTML in canvas, bạn có thể kết xuất DOM thực trực tiếp trong một phần tử canvas. Việc thêm thuộc tính content="drawable" vào một phần tử HTML <canvas> sẽ bố trí các phần tử HTML con của phần tử đó, còn thuộc tính drawable sẽ đánh dấu các phần tử mà bạn có thể kết xuất trong bối cảnh kết xuất của canvas. Bạn có thể dùng API này để tạo kiểu và bố trí văn bản trong canvas, hiển thị nội dung canvas cho các tính năng của trình duyệt (như hỗ trợ tiếp cận, dịch hoặc tìm trong trang) và áp dụng hiệu ứng 2D và 3D cho HTML.
Cách triển khai
Kiểm tra xem trình duyệt có hỗ trợ HTML-in-Canvas hay không:
if ('content' in HTMLCanvasElement.prototype) { // Use HTML in Canvas API } else { // Use fallback strategy }Thêm thuộc tính
content="drawable"vào phần tử HTML<canvas>.Đặt nội dung HTML của bạn vào bên trong phần tử
<canvas>và thêm thuộc tínhdrawablevào mọi phần tử mà bạn vẽ. Một phần tử códrawablesẽ thu thập cây con của phần tử đó, ngoại trừ các phần tử con lồng nhau cũng làdrawable.<canvas id="canvas" content="drawable"> <div id="html-content" drawable></div> </canvas>Điều chỉnh tỷ lệ lưới canvas cho phù hợp với hệ số tỷ lệ của thiết bị để tránh bị mờ:
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);Hiển thị nội dung HTML cho canvas bên trong trình xử lý sự kiện
canvas.onpaint. Bạn chỉ có thể vẽ một phần tử sau khi canvas đã vẽ phần tử đó: hãy gọicanvas.requestPaint()để yêu cầu sự kiệnpaint.
- Trong ngữ cảnh 2D, hãy sử dụng phương thức
drawElementImage. Hàm này vẽ ảnh chụp nhanh gần đây nhất của một phần tửdrawablevà cây con có thể vẽ của phần tử đó, bắt đầu từ hộp đường viền của phần tử, trước khi áp dụng các phép biến đổi CSS:
canvas.onpaint = () => {
ctx.reset();
// Draw the form element at x:0, y:0
ctx.drawElementImage(form_element, 0, 0);
};
- Trong bối cảnh WebGL và WebGPU, bạn sao chép phần tử vào một kết cấu. Định cỡ hoạ tiết từ
canvas.captureElementImage(element).widthvàheightcủa nó nằm trong pixel lưới canvas (kho lưu trữ sao lưu), vì vậy, hãy làm tròn chúng bằngMath.ceil().captureElementImage()sẽ gửi nếu phần tử chưa có bản ghi vẽ: gọirequestPaint()và thử lại trong sự kiệnpainttiếp theo.
// 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;
}
- Trong ngữ cảnh WebGL, trước tiên, hãy phân bổ trước kết cấu sao lưu bằng phương thức
texImage2D. Các đoạn mã trong hướng dẫn này sử dụng ngữ cảnh WebGL 2 (canvas.getContext("webgl2")). Phân bổ bộ nhớ kết cấu bằng cách gọitexImage2D()bằngnullthay vì dữ liệu pixel, giúp dự trữ bộ nhớ GPU có kích thước và định dạng nhất định mà không cần tải bất kỳ pixel nào lên. Hãy tuân theo các quy tắc sau:- Định cỡ hoạ tiết từ
captureElementImage(), như minh hoạ ở trên. - Chỉ phân bổ cấp 0 trong lần tải lên đầu tiên hoặc khi kích thước thay đổi, vì việc phân bổ lại sẽ xoá kết cấu. Sử dụng bộ nhớ có thể thay đổi: không thể đổi kích thước bộ nhớ
texStorage2D()không thể thay đổi khi phần tử thay đổi kích thước.
- Định cỡ hoạ tiết từ
// 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 };
- Sau đó, trong bối cảnh WebGL, hãy kết xuất nội dung HTML vào kết cấu bằng phương thức
texElementSubImage2D. Hãy tuân theo các quy tắc sau:- Truyền
{ width, height }vào cấu hình. Thao tác này giúp bản sao lấp đầy kết cấu được phân bổ bất kể canvas được điều chỉnh theo tỷ lệ như thế nào. - Phân bổ lại kết cấu nếu kích thước thay đổi, như minh hoạ ở trên.
- Truyền
// 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);
}
};
- Trong ngữ cảnh WebGPU, hãy sử dụng phương thức
drawElementImageToTexture. Hãy tuân theo các quy tắc sau:- Tạo hoạ tiết bằng cách sử dụng
COPY_DSTvàRENDER_ATTACHMENT, đồng thời thêmTEXTURE_BINDINGđể lấy mẫu trong chương trình đổ bóng. Cách sử dụng sai sẽ được báo cáo là lỗi xác thực WebGPU, chứ không phải là ngoại lệ. - Truyền kích thước hoạ tiết dưới dạng
size. Nếu không cósize, thì một phần tử lớn hơn sẽ được cắt bớt cho vừa với hoạ tiết. Vớisize, phần tử được điều chỉnh theo tỷ lệ để lấp đầy phần tử đó. - Không thể đổi kích thước hoạ tiết WebGPU. Tạo lại hoạ tiết khi kích thước phần tử thay đổi và tạo lại các nhóm liên kết tham chiếu đến hoạ tiết đó.
- Tạo hoạ tiết bằng cách sử dụng
// 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);
}
};
Khi sử dụng vòng lặp requestAnimationFrame để kết xuất cảnh, hãy gọi canvas.requestPaint() trong vòng lặp để đảm bảo rằng nội dung HTML được kết xuất vào canvas. event.changedElements liệt kê các phần tử có hoạt động kết xuất đã thay đổi. Đảm bảo rằng bạn chỉ vẽ lại phần tử hoặc tải lại kết cấu của phần tử nếu có bản cập nhật cho các phần tử HTML con:
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
};
- Đồng bộ hoá hình học của phần tử.
Trình duyệt cần biết vị trí vẽ phần tử HTML để vị trí DOM của phần tử đó khớp với các pixel được vẽ. Ví dụ: thử nghiệm nhấn cho các lượt nhấp và lựa chọn văn bản, cũng như khả năng hỗ trợ tiếp cận sử dụng hình học này: một phần tử drawable có hình học không được cập nhật vẫn hiển thị với cây hỗ trợ tiếp cận, nhưng không có thông tin hình học. Canvas chỉ thử nghiệm nhấn các phần tử được vẽ bằng phương thức 2D drawElementImage() hoặc được đăng ký bằng updateElementGeometry().
Đối với trường hợp ngữ cảnh 2D, trình duyệt sẽ tự động đồng bộ hoá phần tử.
drawElementImage()điều chỉnh ranh giới thử nghiệm nhấn DOM và vòng lấy tiêu điểm của trình đọc màn hình theo toạ độ được vẽ, vì vậy, bạn không cần đặtstyle.transform. Để quản lý hình học theo cách thủ công, ví dụ: khi bạn vẽ cùng một phần tử nhiều lần, hãy truyền{ preserveElementGeometry: true }đếndrawElementImage()và áp dụng các phép biến đổi bằngcanvas.updateElementGeometry().Đối với trường hợp 3D có WebGL hoặc WebGPU, canvas không thể biết phần tử HTML được vẽ ở đâu, vì vậy, bạn phải cho canvas biết. Tính toán một
DOMMatrixliên kết hộp viền của phần tử (tính bằng pixel CSS) với canvas (tính bằng pixel CSS). Trình duyệt cần liên kết từ không gian toạ độ 3D vào không gian toạ độ CSS bằng cách sử dụng phép biến đổi khung nhìn. Để hỗ trợ việc này, hãy làm như sau:- Chuẩn hoá phần tử HTML. Các phần tử HTML được định kích thước bằng pixel (ví dụ: rộng 200 pixel). Tuy nhiên, WebGL thường coi các đối tượng là "hình vuông đơn vị", ví dụ: từ -0,5 đến 0, 5. Nếu bạn không chuẩn hoá, nút 200px của bạn sẽ trông lớn hơn 200 lần. Bước này cũng lật trục Y, vì trong CSS, hướng xuống là dương, nhưng trong WebGL, hướng lên là dương.
- Chuyển đổi Ma trận MVP WebGL thành Ma trận DOM.
- Ánh xạ tới khung nhìn canvas. Bước này là giai đoạn "điều chỉnh lại tỷ lệ": bước này kéo giãn toán học không gian đơn vị đó ra để khớp với kích thước pixel CSS của phần tử
<canvas>trên màn hình và lật lại trục Y. - Tính toán phép biến đổi cuối cùng. Nhân các ma trận theo thứ tự: Khung nhìn * MVP * Chuẩn hoá. Việc kết hợp các phép biến đổi này thành một phép biến đổi cuối cùng sẽ tạo ra một "bản đồ" cho trình duyệt biết chính xác vị trí của phần tử HTML đó để căn chỉnh với bản vẽ 3D. Trình duyệt thực hiện phép chia phối cảnh.
// 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); }
Sau đó, truyền phép biến đổi đến canvas.updateElementGeometry() dưới dạng canvasTransform. Thao tác này sẽ di chuyển phần tử HTML để đặt trực tiếp lên trên các pixel được kết xuất. Điều này đảm bảo rằng khi người dùng nhấp vào một nút hoặc chọn văn bản, họ thực sự đang nhấp vào phần tử HTML thực. Trình duyệt cũng sử dụng phép biến đổi cho các ranh giới hỗ trợ tiếp cận của phần tử, ví dụ: cho vòng lấy tiêu điểm của trình đọc màn hình:
canvas.updateElementGeometry(targetHTMLElement, {
canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
});
Sử dụng các tuỳ chọn này để kiểm soát thử nghiệm nhấn:
- Mỗi lệnh gọi
updateElementGeometry()sẽ di chuyển phần tử lên đầu thứ tự thử nghiệm nhấn. Thẻ/vé{ preserveHitTestOrder: true }sẽ giữ nguyên vị trí. - Gọi
canvas.clearElementGeometry(element)để xoá một phần tử khỏi thử nghiệm nhấn, ví dụ: khi bạn ẩn phần tử đó.
Mã mẫu
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>
Các phương pháp hay nhất
- BẮT BUỘC: Kiểm tra khả năng hỗ trợ của trình duyệt đối với API HTML-in-Canvas trước khi sử dụng.
- BẮT BUỘC: Luôn thêm thuộc tính
content="drawable"vào phần tử<canvas>. - BẮT BUỘC: Thêm thuộc tính
drawablevào mọi phần tử mà bạn vẽ, bao gồm cả các phần tử con trực tiếp của canvas. - BẮT BUỘC: Sử dụng trình xử lý sự kiện
onpaintđể hiển thị nội dung HTML trên canvas. - BẮT BUỘC: Sử dụng phương thức
drawElementImagetrong 2D hoặcdrawElementImageToTexturetrong WebGPU để kết xuất nội dung HTML vào canvas. - BẮT BUỘC: Trong WebGL, hãy phân bổ trước vùng đệm kết cấu bằng
texImage2D()một lần (khi vùng đệm được khởi chạy hoặc khi kích thước phần tử thay đổi). Chỉ phân bổ lại kết cấu khi kích thước thay đổi và đặtTEXTURE_MIN_FILTERthànhLINEAR. - BẮT BUỘC: Trong WebGL, hãy sử dụng phương thức
texElementSubImage2Dđể kết xuất nội dung HTML thành một hoạ tiết mà bạn phân bổ trước bằngtexImage2D(), có kích thước từcaptureElementImage(). - BẮT BUỘC: Trong WebGPU, hãy định kích thước hoạ tiết từ
captureElementImage()và chỉ tạo lại hoạ tiết khi kích thước thay đổi. Tạo kết cấu bằng cách sử dụngCOPY_DSTvàRENDER_ATTACHMENT, đồng thời truyền kích thước kết cấu dưới dạngsize. - BẮT BUỘC: Đồng bộ hoá hình học của phần tử HTML với nội dung được kết xuất. Trong 2D,
drawElementImagesẽ tự động thực hiện việc này. Trong WebGL và WebGPU, hãy gọicanvas.updateElementGeometry(element, { canvasTransform }). - BẮT BUỘC: Sử dụng
ResizeObserverđể quan sát kích thước màn hình và cập nhật kích thước canvas cho phù hợp với pixel của thiết bị. - KHÔNG nhúng nội dung trên nhiều nguồn gốc vào canvas vì tính năng này không được hỗ trợ.
- KHÔNG khởi động
ResizeObservertrong trình xử lý sự kiệnonpaintvì việc này có thể dẫn đến rò rỉ bộ nhớ.
Chiến lược dự phòng
API HTML-in-Canvas hiện không được hỗ trợ trong tất cả các trình duyệt hiện đại, do đó, bạn thường cần có chiến lược dự phòng.
Tuy nhiên, do lợi ích về hiệu suất được cải thiện của API này, bạn nên sử dụng HTML-in-Canvas nếu trình duyệt hỗ trợ.
Chiến lược dự phòng phụ thuộc vào trường hợp sử dụng. Ví dụ: đối với nội dung HTML tương tác trong canvas, nếu HTML-in-Canvas không được hỗ trợ, hãy đặt nội dung HTML lên trên canvas bằng CSS.
Polyfill HTML trong Canvas
Sử dụng tập lệnh polyfill sau đây để mô phỏng HTML-in-Canvas API trong các trình duyệt không hỗ trợ API này.
Cài đặt hoặc nhúng thư viện:
# Install npm install three-html-render# Embed <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>Chạy phương thức
installHtmlInCanvasPolyfill()để dịch HTML-in-Canvas.