╭ changed ──────────╮ │src/embed/script.js│ ╰───────────────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │1 const grid = document.getElementById("grid"); │ │2 -const pageJsonBase64 = document.getElementById("page").textContent; │ │2 +const overlay = document.getElementById("overlay"); │ │3 +const pageJsonBase64 = document.getElementById("page-data").textContent;│ │4 │ │5 let wasmInstance; │ │6 const decoder = new TextDecoder(); │ ╰───────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │ 7 const encoder = new TextEncoder(); │ │ 8 let currentHtml = ""; │ │ 9 +// seeded from the server-rendered overlay so the first wasm tick can no-op│ │10 +// when its layout happens to match the server's. otherwise the first tick │ │11 +// always rebuilds and any pre-load focus state is lost. │ │12 +let currentOverlay = overlay.innerHTML; │ │13 │ │14 const MIN_COLS = 30; │ │15 │ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │57 const html = readWasmString(ptr, len); │ │58 if (html === currentHtml) return; │ │59 currentHtml = html; │ │55 - // capture which focusable element had focus + (for inputs) its │ │56 - // cursor position so we can restore it after innerHTML wipes │ │57 - // and re-creates the elements. covers both overlay inputs AND │ │58 - // tabbable submit-button spans; otherwise focus on the latter │ │59 - // would be lost on every re-render, and Enter would fall │ │60 - // through to the wasm key dispatch instead of being intercepted │ │61 - // as a form submission. │ │62 - const active = document.activeElement; │ │63 - let savedFocusId = null; │ │64 - let savedStart = null; │ │65 - let savedEnd = null; │ │66 - if (active && active.dataset && active.dataset.focusId) { │ │67 - savedFocusId = active.dataset.focusId; │ │68 - if (active.tagName === "INPUT") { │ │69 - savedStart = active.selectionStart; │ │70 - savedEnd = active.selectionEnd; │ │71 - } │ │72 - } │ │60 + // the grid only holds non-focusable TUI cell spans now; the │ │61 + // form (inputs + submit button) lives in #overlay and is │ │62 + // never touched by this wipe, so we don't need to save and │ │63 + // restore focus across the swap. │ │64 grid.innerHTML = html; │ │74 - // prefer whatever wasm marked as focused — that way arrow-key │ │75 - // navigation in the TUI also moves DOM focus to the matching │ │76 - // <input>. fall back to the previously focused element so │ │77 - // typing doesn't lose its caret position on re-renders that │ │78 - // don't change focus. │ │79 - const focused = grid.querySelector('[data-focused="true"]'); │ │80 - const target = focused || (savedFocusId !== null │ │81 - ? grid.querySelector(`[data-focus-id="${savedFocusId}"]`) │ │82 - : null); │ │83 - if (target && typeof target.focus === "function") { │ │84 - target.focus(); │ │85 - // only restore selection when we land on the same element │ │86 - // we captured from; otherwise the offsets are meaningless. │ │87 - if (savedStart !== null && target.dataset && target.dataset.focusId === savedFocusId && target.setSelectionRange) {│ │88 - try { target.setSelectionRange(savedStart, savedEnd); } catch (_) {} │ │89 - } │ │90 - } │ │65 + }, │ │66 + _setOverlay: function (ptr, len) { │ │67 + const html = readWasmString(ptr, len); │ │68 + // diff against the previous overlay HTML; an unchanged overlay │ │69 + // means no layout/structure has shifted (typing alone doesn't │ │70 + // produce a diff because we deliberately omit the `value` │ │71 + // attribute on inputs — the browser tracks that). skipping the │ │72 + // innerHTML assignment keeps the live <form> alive across the │ │73 + // mousedown→focusin→tick→click sequence, which is what lets │ │74 + // the very first click on the submit button actually submit. │ │75 + if (html === currentOverlay) return; │ │76 + currentOverlay = html; │ │77 + overlay.innerHTML = html; │ │78 }, │ │79 }, │ │80 }; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 87 wasmInstance.exports._tick(minRows(), maxCols()); │ │ 88 } │ │ 89 │ │103 -// POST the current input values to the server form route. on response (any │ │104 -// status) we navigate to / so the server can render the page with whatever │ │105 -// session cookie it just set. │ │106 -function submitForm(url) { │ │107 - const params = new URLSearchParams(); │ │108 - const inputs = grid.querySelectorAll("input"); │ │109 - for (const input of inputs) { │ │110 - if (input.name) params.append(input.name, input.value); │ │111 - } │ │112 - fetch(url, { method: "POST", body: params, redirect: "manual", credentials: "same-origin" })│ │113 - .finally(() => { window.location.href = "/"; }); │ │114 -} │ │115 - │ │ 90 WebAssembly.instantiateStreaming(fetch("haxy.wasm"), importObject).then(async (result) => { │ │ 91 wasmInstance = result.instance; │ │ 92 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │105 wasmInstance.exports._start(ptr, jsonBytes.length, minRows(), maxCols()); │ │106 │ │107 document.addEventListener("keydown", (event) => { │ │134 - // when an overlay input owns focus, the browser handles typing & │ │135 - // cursor movement natively; wasm gets the new value via input events │ │136 - // instead of per-key dispatch. don't forward, and don't intercept │ │137 - // arrow / home / end which the input needs for cursor movement. │ │138 - // exception: Enter triggers form submission when one is on the page. │ │139 - if (document.activeElement && document.activeElement.tagName === "INPUT") { │ │140 - if (event.key === "Enter") { │ │141 - event.preventDefault(); │ │142 - const submitSpan = grid.querySelector('[data-action="submit"]'); │ │143 - if (submitSpan) { │ │144 - submitForm(submitSpan.dataset.url); │ │145 - } │ │146 - } │ │147 - return; │ │148 - } │ │149 - // a focused submit-button span (Tab can land here now that it has a │ │150 - // tabindex) should submit on Enter the same as a click does. │ │151 - if (event.key === "Enter" && document.activeElement && document.activeElement.dataset && document.activeElement.dataset.action === "submit") {│ │152 - event.preventDefault(); │ │153 - submitForm(document.activeElement.dataset.url); │ │154 - return; │ │108 + // when a form element (text input or submit button) owns focus, │ │109 + // the browser handles typing, Enter-to-submit, and Tab natively. │ │110 + // we only forward keys into the TUI when focus is elsewhere. │ │111 + if (document.activeElement) { │ │112 + const tag = document.activeElement.tagName; │ │113 + if (tag === "INPUT" || tag === "BUTTON") return; │ │114 } │ │115 if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "PageUp", "PageDown", "Home", "End"].includes(event.key)) { │ │116 event.preventDefault(); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │119 wasmInstance.exports._tick(minRows(), maxCols()); │ │120 }); │ │121 │ │163 - // event delegation: each render rebuilds the input elements (innerHTML │ │164 - // wipe), so we can't attach listeners per-input. delegating on grid │ │165 - // catches inputs created at any point. │ │166 - grid.addEventListener("input", (event) => { │ │122 + // listeners on document (not #grid) since the form lives outside #grid;│ │123 + // input/focus events from form elements still bubble up to document. │ │124 + document.addEventListener("input", (event) => { │ │125 const t = event.target; │ │126 if (!t || t.tagName !== "INPUT" || !t.dataset.focusId) return; │ │127 sendTextInputValue(Number(t.dataset.focusId), t.value); │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │128 }); │ │129 │ │172 - grid.addEventListener("focusin", (event) => { │ │130 + document.addEventListener("focusin", (event) => { │ │131 const t = event.target; │ │132 if (!t || !t.dataset || !t.dataset.focusId) return; │ │133 wasmInstance.exports._onMouseClick(Number(t.dataset.focusId));│ ╰───────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │134 wasmInstance.exports._tick(minRows(), maxCols()); │ │177 - }); │ │178 - │ │179 - // submit handled on mousedown rather than click because submit-button│ │180 - // spans are tabbable: a mouse click on one moves focus to it, which │ │181 - // fires focusin, which re-renders via innerHTML, which detaches the │ │182 - // very span the click event was about to target. mousedown fires │ │183 - // before the focus change, so the target is guaranteed to be live. │ │184 - grid.addEventListener("mousedown", (event) => { │ │185 - const span = event.target.closest(".clickable"); │ │186 - if (!span || span.dataset.action !== "submit") return; │ │187 - event.preventDefault(); │ │188 - submitForm(span.dataset.url); │ │135 }); │ │136 │ │137 grid.addEventListener("click", (event) => { │ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │138 const span = event.target.closest(".clickable"); │ │139 if (!span) return; │ │194 - // submit is handled in mousedown above │ │195 - if (span.dataset.action === "submit") { │ │196 - event.preventDefault(); │ │197 - return; │ │198 - } │ │140 const focusId = Number(span.dataset.focusId); │ │141 wasmInstance.exports._onMouseClick(focusId); │ │201 - // synthesize Enter so the focused widget's "activate" handler fires│ │202 - // (e.g. a button click runs its submit handler). widgets that don't│ │203 - // care about Enter just ignore it. │ │204 - wasmInstance.exports._onKeyDown(13); │ │142 wasmInstance.exports._tick(minRows(), maxCols()); │ │143 }); │ │144 │ ╰─────────────────────────────────────────────────────────────────────────────────╯