|
1 | 1 | # WebAssembly Custom Elements
|
2 | 2 |
|
| 3 | +Extend HTML with Custom HTML Elements, and make them interactive with JavaScript. |
| 4 | + |
| 5 | +## `<wasm-html>` |
| 6 | + |
| 7 | +The element lets you use a WebAssembly instance that renders HTML. It loads your WebAssembly module for you, and instantiates it hooking into your exports. It expects specific exports: |
| 8 | + |
| 9 | +- a `memory` exported under the name `"memory"`, which it will read the HTML text from (using the `MemoryIO` helper class below). |
| 10 | +- a `to_html()` function that returns the memory offset to your built HTML. This will be called on each render. |
| 11 | +- an optional `free_all()` function that is called at the start of each render, used to free memory if you need. |
| 12 | + |
| 13 | +If your rendered HTML includes a `<button>` with a `data-action` attribute, then a click listener will be added. The value of this attribute if set to the name of an exported function, will be called every time the button is clicked. The HTML will also be re-rendered for you. |
| 14 | + |
| 15 | +For example a `<button data-action="increment">Increment counter</button>` will call the `increment()` function you export, plus call your `to_html()` function, allowing you to re-render say a counter from `<output>1</output>` to `<output>2</output>`. Note: you must render the button each time: this allow you to change which buttons are available depending on your internal state. |
| 16 | + |
| 17 | +### Usage |
| 18 | + |
| 19 | +```html |
| 20 | +<wasm-html class="block"> |
| 21 | + <source src="url/to/your/module.wasm" type="application/wasm" /> |
| 22 | +</wasm-html> |
| 23 | +``` |
| 24 | + |
| 25 | +### Source |
| 26 | + |
| 27 | +```js |
| 28 | +class WasmHTML extends HTMLElement { |
| 29 | + connectedCallback() { |
| 30 | + const wasmURL = |
| 31 | + this.getAttribute("src") ?? |
| 32 | + this.querySelector("source[type='application/wasm']")?.src; |
| 33 | + if (!wasmURL) throw Error("Expected wasm URL as 'src' attribute or child <source>"); |
| 34 | + |
| 35 | + const wasmModulePromise = WebAssembly.compileStreaming( |
| 36 | + fetch(wasmURL, { credentials: "omit" }); |
| 37 | + initWasmHTML(this, wasmModulePromise); |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +async function initWasmHTML(el, wasmModulePromise) { |
| 42 | + const wasmModule = await wasmModulePromise; |
| 43 | + |
| 44 | + let memoryIO; |
| 45 | + const imports = { |
| 46 | + math: { |
| 47 | + powf32: (x, y) => Math.pow(x, y), |
| 48 | + }, |
| 49 | + format: { |
| 50 | + f32: (f, memoryOffset) => { |
| 51 | + let s = String(f); |
| 52 | + // We always want a `.0` suffix, even for integers. |
| 53 | + if (!/[.]/.test(s)) { |
| 54 | + s = f.toFixed(1); |
| 55 | + } |
| 56 | + return memoryIO.writeStringAt(s, memoryOffset); |
| 57 | + }, |
| 58 | + }, |
| 59 | + log: { |
| 60 | + i32: (i) => console.log("wasm", i), |
| 61 | + f32: (f) => console.log("wasm", f), |
| 62 | + }, |
| 63 | + }; |
| 64 | + const instance = await WebAssembly.instantiate(wasmModule, imports); |
| 65 | + |
| 66 | + memoryIO = new MemoryIO(instance.exports); |
| 67 | + const { to_html: toHTML, free_all: freeAll } = instance.exports; |
| 68 | + |
| 69 | + function update() { |
| 70 | + freeAll?.apply(); |
| 71 | + const html = memoryIO.readString(toHTML()); |
| 72 | + el.innerHTML = html; |
| 73 | + } |
| 74 | + |
| 75 | + // See definition below. |
| 76 | + addEventListenersToWasmInstance(instance, update); |
| 77 | + |
| 78 | + // Schedule initial update. |
| 79 | + queueMicrotask(update); |
| 80 | +} |
| 81 | + |
| 82 | +customElements.define("wasm-html", WasmHTML); |
| 83 | +``` |
| 84 | +
|
| 85 | +## Event listeners |
| 86 | +
|
| 87 | +```js |
| 88 | +function addEventListenersToWasmInstance(instance, update) { |
| 89 | + el.addEventListener("click", (event) => { |
| 90 | + const action = event.target.dataset.action; |
| 91 | + if (typeof action === "string") { |
| 92 | + instance.exports[action]?.apply(); |
| 93 | + update(); |
| 94 | + } |
| 95 | + }); |
| 96 | + |
| 97 | + el.addEventListener("pointerdown", (event) => { |
| 98 | + if (event.buttons === 1) { |
| 99 | + const actionTarget = event.target.closest("[data-action"); |
| 100 | + if (actionTarget == null) return; |
| 101 | + |
| 102 | + const action = actionTarget.dataset.pointerdown; |
| 103 | + if (typeof action === "string") { |
| 104 | + instance.exports[action]?.apply(); |
| 105 | + instance.exports["pointerdown_offset"]?.apply(null, [ |
| 106 | + event.offsetX, |
| 107 | + event.offsetY, |
| 108 | + ]); |
| 109 | + update(); |
| 110 | + } |
| 111 | + } |
| 112 | + }); |
| 113 | + |
| 114 | + el.addEventListener("pointermove", (event) => { |
| 115 | + if (event.buttons === 1) { |
| 116 | + const actionTarget = event.target.closest("[data-action"); |
| 117 | + if (actionTarget == null) return; |
| 118 | + |
| 119 | + const action = actionTarget.dataset["pointerdown+pointermove"]; |
| 120 | + if (typeof action === "string") { |
| 121 | + // instance.exports[action]?.apply(); |
| 122 | + instance.exports["pointermove_offset"]?.apply(null, [ |
| 123 | + event.offsetX, |
| 124 | + event.offsetY, |
| 125 | + ]); |
| 126 | + update(); |
| 127 | + } |
| 128 | + } |
| 129 | + }); |
| 130 | +} |
| 131 | +``` |
| 132 | +
|
3 | 133 | ## MemoryIO
|
4 | 134 |
|
5 | 135 | ```js
|
|
0 commit comments