╭ script.js ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const grid = document.getElementById("grid"); 2 const pageJsonBase64 = document.getElementById("page").textContent; 3 4 let wasmInstance; 5 const decoder = new TextDecoder(); 6 const encoder = new TextEncoder(); 7 let currentHtml = ""; 8 9 const MIN_COLS = 30; 10 11 // measured once after the font is loaded. recomputed lazily if it ever 12 // reads back as zero (e.g. measured before the font was actually ready). 13 let cellWidth = null; 14 15 function measureCellWidth() { 16 const probe = document.createElement("span"); 17 probe.style.position = "absolute"; 18 probe.style.visibility = "hidden"; 19 probe.style.whiteSpace = "pre"; 20 probe.textContent = "X".repeat(100); 21 grid.appendChild(probe); 22 const w = probe.getBoundingClientRect().width / 100; 23 probe.remove(); 24 return w; 25 } 26 27 function maxCols() { 28 if (!cellWidth) cellWidth = measureCellWidth(); 29 return Math.max(MIN_COLS, Math.floor(document.body.clientWidth / cellWidth)); 30 } 31 32 function readWasmString(ptr, len) { 33 const memory = wasmInstance.exports.memory; 34 return decoder.decode(new Uint8Array(memory.buffer, ptr, len)); 35 } 36 37 const importObject = { 38 env: { 39 _consoleLog: function (ptr, len) { 40 console.log(readWasmString(ptr, len)); 41 }, 42 _setHtml: function (ptr, len) { 43 const html = readWasmString(ptr, len); 44 if (html === currentHtml) return; 45 currentHtml = html; 46 // capture which focusable element had focus + (for inputs) its 47 // cursor position so we can restore it after innerHTML wipes 48 // and re-creates the elements. covers both overlay inputs AND 49 // tabbable submit-button spans; otherwise focus on the latter 50 // would be lost on every re-render, and Enter would fall 51 // through to the wasm key dispatch instead of being intercepted 52 // as a form submission. 53 const active = document.activeElement; 54 let savedFocusId = null; 55 let savedStart = null; 56 let savedEnd = null; 57 if (active && active.dataset && active.dataset.focusId) { 58 savedFocusId = active.dataset.focusId; 59 if (active.tagName === "INPUT") { 60 savedStart = active.selectionStart; 61 savedEnd = active.selectionEnd; 62 } 63 } 64 grid.innerHTML = html; 65 if (savedFocusId !== null) { 66 const restored = grid.querySelector(`[data-focus-id="${savedFocusId}"]`); 67 if (restored && typeof restored.focus === "function") { 68 restored.focus(); 69 if (savedStart !== null && restored.setSelectionRange) { 70 try { restored.setSelectionRange(savedStart, savedEnd); } catch (_) {} 71 } 72 } 73 } 74 }, 75 }, 76 }; 77 78 function sendTextInputValue(focusId, value) { 79 const bytes = encoder.encode(value); 80 const ptr = wasmInstance.exports._alloc(bytes.length); 81 new Uint8Array(wasmInstance.exports.memory.buffer, ptr, bytes.length).set(bytes); 82 wasmInstance.exports._setTextInputValue(focusId, ptr, bytes.length); 83 wasmInstance.exports._tick(maxCols()); 84 } 85 86 // POST the current input values to the server form route. on response (any 87 // status) we navigate to / so the server can render the page with whatever 88 // session cookie it just set. 89 function submitForm(url) { 90 const params = new URLSearchParams(); 91 const inputs = grid.querySelectorAll("input"); 92 for (const input of inputs) { 93 if (input.name) params.append(input.name, input.value); 94 } 95 fetch(url, { method: "POST", body: params, redirect: "manual", credentials: "same-origin" }) 96 .finally(() => { window.location.href = "/"; }); 97 } 98 99 WebAssembly.instantiateStreaming(fetch("haxy.wasm"), importObject).then(async (result) => { 100 wasmInstance = result.instance; 101 102 // wait for the @font-face to load before measuring cell width, otherwise 103 // we get the fallback font's metrics. 104 if (document.fonts && document.fonts.ready) { 105 await document.fonts.ready; 106 } 107 108 // the page is embedded as base64 so it can sit inside the host html 109 // without worrying about characters that would terminate the script tag. 110 // decode here and hand the raw json bytes to the wasm. 111 const jsonBytes = Uint8Array.from(atob(pageJsonBase64), (c) => c.charCodeAt(0)); 112 const ptr = wasmInstance.exports._alloc(jsonBytes.length); 113 new Uint8Array(wasmInstance.exports.memory.buffer, ptr, jsonBytes.length).set(jsonBytes); 114 wasmInstance.exports._start(ptr, jsonBytes.length, maxCols()); 115 116 document.addEventListener("keydown", (event) => { 117 // when an overlay input owns focus, the browser handles typing & 118 // cursor movement natively; wasm gets the new value via input events 119 // instead of per-key dispatch. don't forward, and don't intercept 120 // arrow / home / end which the input needs for cursor movement. 121 // exception: Enter triggers form submission when one is on the page. 122 if (document.activeElement && document.activeElement.tagName === "INPUT") { 123 if (event.key === "Enter") { 124 event.preventDefault(); 125 const submitSpan = grid.querySelector('[data-action="submit"]'); 126 if (submitSpan) { 127 submitForm(submitSpan.dataset.url); 128 } 129 } 130 return; 131 } 132 // a focused submit-button span (Tab can land here now that it has a 133 // tabindex) should submit on Enter the same as a click does. 134 if (event.key === "Enter" && document.activeElement && document.activeElement.dataset && document.activeElement.dataset.action === "submit") { 135 event.preventDefault(); 136 submitForm(document.activeElement.dataset.url); 137 return; 138 } 139 if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "PageUp", "PageDown", "Home", "End"].includes(event.key)) { 140 event.preventDefault(); 141 } 142 wasmInstance.exports._onKeyDown(event.keyCode); 143 wasmInstance.exports._tick(maxCols()); 144 }); 145 146 // event delegation: each render rebuilds the input elements (innerHTML 147 // wipe), so we can't attach listeners per-input. delegating on grid 148 // catches inputs created at any point. 149 grid.addEventListener("input", (event) => { 150 const t = event.target; 151 if (!t || t.tagName !== "INPUT" || !t.dataset.focusId) return; 152 sendTextInputValue(Number(t.dataset.focusId), t.value); 153 }); 154 155 grid.addEventListener("focusin", (event) => { 156 const t = event.target; 157 if (!t || !t.dataset || !t.dataset.focusId) return; 158 wasmInstance.exports._onMouseClick(Number(t.dataset.focusId)); 159 wasmInstance.exports._tick(maxCols()); 160 }); 161 162 grid.addEventListener("click", (event) => { 163 const span = event.target.closest(".clickable"); 164 if (!span) return; 165 if (span.dataset.action === "submit") { 166 event.preventDefault(); 167 submitForm(span.dataset.url); 168 return; 169 } 170 const focusId = Number(span.dataset.focusId); 171 wasmInstance.exports._onMouseClick(focusId); 172 // synthesize Enter so the focused widget's "activate" handler fires 173 // (e.g. a button click runs its submit handler). widgets that don't 174 // care about Enter just ignore it. 175 wasmInstance.exports._onKeyDown(13); 176 wasmInstance.exports._tick(maxCols()); 177 }); 178 179 let resizeTimer = null; 180 window.addEventListener("resize", () => { 181 clearTimeout(resizeTimer); 182 resizeTimer = setTimeout(() => { 183 wasmInstance.exports._tick(maxCols()); 184 }, 100); 185 }); 186 }); 187