╭ changed ───────────╮ │src/embed/index.html│ ╰────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │103 } │ │104 │ │105 /* the TUI cells under the button render the border + label;│ │106 - the overlay button stays transparent so they show through│ │106 + the native button stays transparent so they show through │ │107 and just owns the click + form-submit behavior. */ │ │108 .page button { │ │109 background: transparent; │ ╰───────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │125 <body> │ │126 <div class="page"> │ │127 <div id="grid">{{{ HAXY_HTML }}}</div> │ │128 - <div id="overlay">{{{ HAXY_OVERLAY }}}</div> │ │128 </div> │ │129 <script id="page-data" type="application/json">{{{ HAXY_JSON }}}</script>│ │130 <script src="/script.js"></script> │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──────────╮ │src/embed/script.js│ ╰───────────────────╯ ╭─────────────────────────────────────────────────────────────────────╮ │1 const grid = document.getElementById("grid"); │ │2 -const overlay = document.getElementById("overlay"); │ │2 const pageJson = document.getElementById("page-data").textContent;│ │3 │ │4 let wasmInstance; │ ╰─────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────╮ │ 5 const decoder = new TextDecoder(); │ │ 6 const encoder = new TextEncoder(); │ │ 8 -let currentHtml = ""; │ │ 9 -// each scroll div's wasm-side offset (in cells) as of the last render, keyed by│ │10 -// scroll id. when a new render's offset differs, the wasm moved the scroll on │ │11 -// purpose and its offset is applied; otherwise the browser position (the user's│ │12 -// scrolling) is preserved. │ │13 -const wasmOffsets = {}; │ │14 -// seeded from the server-rendered overlay so the first wasm tick can no-op │ │15 -// when its layout happens to match the server's. otherwise the first tick │ │16 -// always rebuilds and any pre-load focus state is lost. │ │17 -let currentOverlay = overlay.innerHTML; │ │ 7 +// seed the render snapshot from the server html. if wasm produces the same │ │ 8 +// first frame, the live DOM does not need even a reconciliation pass. │ │ 9 +let currentHtml = grid.innerHTML; │ │10 │ │11 const MIN_COLS = 30; │ │12 │ ╰────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │45 return decoder.decode(new Uint8Array(memory.buffer, ptr, len)); │ │46 } │ │47 │ │48 +function focusElements(id) { │ │49 + // a native control is the authoritative geometry for its widget. the cell │ │50 + // layer also contains spans with this id, but those are only its backdrop. │ │51 + const control = grid.querySelector(`[data-key="control:${id}"]`); │ │52 + return control ? [control] : Array.from(grid.querySelectorAll(`[data-focus-id="${id}"]`));│ │53 +} │ │54 + │ │55 function focusedNativeScroll() { │ │56 const id = grid.dataset.focusId; │ │57 if (!id) return null; │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────╮ │59 - const focused = grid.querySelectorAll(`[data-focus-id="${id}"]`);│ │58 + const focused = focusElements(id); │ │59 if (focused.length === 0) return null; │ │60 const container = focused[0].closest(".scroll"); │ │61 return container ? { focused, container } : null; │ ╰─────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 92 if (chosen) wasmInstance.exports._setFocus(Number(chosen.dataset.focusId)); │ │ 93 wasmInstance.exports._tick(minRows(), maxCols()); │ │ 94 return true; │ │ 95 +} │ │ 96 + │ │ 97 +// stateful controls, scroll panels, and form owners are keyed. everything │ │ 98 +// unkeyed is replaced wholesale, including each panel's cell layer. │ │ 99 +function nodeKey(node) { │ │100 + if (node.nodeType !== Node.ELEMENT_NODE) return null; │ │101 + return node.dataset.key || null; │ │102 +} │ │103 + │ │104 +function nodesMatch(current, desired) { │ │105 + if (nodeKey(current) !== nodeKey(desired) || current.tagName !== desired.tagName) return false; │ │106 + return current.tagName !== "INPUT" || current.type === desired.type; │ │107 +} │ │108 + │ │109 +function syncAttributes(current, desired) { │ │110 + const preserveValue = desired.hasAttribute("data-preserve-value"); │ │111 + for (const attr of Array.from(current.attributes)) { │ │112 + if (!desired.hasAttribute(attr.name)) current.removeAttribute(attr.name); │ │113 + } │ │114 + for (const attr of Array.from(desired.attributes)) { │ │115 + // updating the default value of a dirty input is usually harmless, but │ │116 + // avoiding it entirely gives browsers no opportunity to disturb the │ │117 + // live value, selection, undo stack, or an open file picker. │ │118 + if (preserveValue && attr.name === "value") continue; │ │119 + if (current.getAttribute(attr.name) !== attr.value) current.setAttribute(attr.name, attr.value); │ │120 + } │ │121 + │ │122 + if (current.tagName === "INPUT" && !preserveValue && current.type !== "file" && current.value !== desired.value) {│ │123 + current.value = desired.value; │ │124 + } │ │125 +} │ │126 + │ │127 +function patchNode(current, desired) { │ │128 + syncAttributes(current, desired); │ │129 + if (current.tagName === "TEXTAREA") { │ │130 + if (!desired.hasAttribute("data-preserve-value")) { │ │131 + if (current.value !== desired.value) current.value = desired.value; │ │132 + if (current.defaultValue !== desired.defaultValue) current.defaultValue = desired.defaultValue; │ │133 + } │ │134 + return; │ │135 + } │ │136 + │ │137 + patchChildren(current, desired); │ │138 +} │ │139 + │ │140 +function patchChildren(current, desired) { │ │141 + const keyed = new Map(); │ │142 + for (const child of Array.from(current.childNodes)) { │ │143 + const key = nodeKey(child); │ │144 + if (key) keyed.set(key, child); │ │145 + } │ │146 + │ │147 + let cursor = current.firstChild; │ │148 + for (const wanted of Array.from(desired.childNodes)) { │ │149 + const key = nodeKey(wanted); │ │150 + if (!key) { │ │151 + const replacement = wanted.cloneNode(true); │ │152 + if (cursor && !nodeKey(cursor)) { │ │153 + const next = cursor.nextSibling; │ │154 + current.replaceChild(replacement, cursor); │ │155 + cursor = next; │ │156 + } else { │ │157 + current.insertBefore(replacement, cursor); │ │158 + } │ │159 + continue; │ │160 + } │ │161 + │ │162 + let found = keyed.get(key); │ │163 + if (found && !nodesMatch(found, wanted)) found = null; │ │164 + if (!found) { │ │165 + found = wanted.cloneNode(true); │ │166 + current.insertBefore(found, cursor); │ │167 + } else { │ │168 + keyed.delete(key); │ │169 + while (cursor && cursor !== found && !nodeKey(cursor)) { │ │170 + const next = cursor.nextSibling; │ │171 + cursor.remove(); │ │172 + cursor = next; │ │173 + } │ │174 + if (found !== cursor) current.insertBefore(found, cursor); │ │175 + patchNode(found, wanted); │ │176 + } │ │177 + cursor = found.nextSibling; │ │178 + } │ │179 + │ │180 + while (cursor) { │ │181 + const next = cursor.nextSibling; │ │182 + cursor.remove(); │ │183 + cursor = next; │ │184 + } │ │185 +} │ │186 + │ │187 +function patchHtml(container, html) { │ │188 + const template = document.createElement("template"); │ │189 + template.innerHTML = html; │ │190 + patchChildren(container, template.content); │ │191 } │ │192 │ │193 const importObject = { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │199 const html = readWasmString(ptr, len); │ │200 if (html === currentHtml) return; │ │201 currentHtml = html; │ │107 - // each web-native Scroll renders as a .scroll div; replacing │ │108 - // innerHTML recreates them and would reset their scroll position │ │109 - // every tick, so snapshot scrollTop/Left by their stable id and │ │110 - // restore it after the swap. (mouse-wheel scrolling happens with no │ │111 - // tick, so it isn't affected.) │ │112 - const positions = {}; │ │113 - for (const el of grid.querySelectorAll(".scroll[data-scroll-id]")) { │ │114 - positions[el.dataset.scrollId] = { top: el.scrollTop, left: el.scrollLeft }; │ │202 + // snapshot browser and wasm scroll state before reconciling. a stable │ │203 + // scroll with the same version and wasm offset keeps its browser │ │204 + // position; new content or an intentional wasm move uses the offset. │ │205 + const scrolls = new Map(); │ │206 + for (const el of grid.querySelectorAll(".scroll[data-key]")) { │ │207 + scrolls.set(el.dataset.key, { │ │208 + id: el.dataset.scrollId, │ │209 + offsetTop: Number(el.dataset.scrollY || 0), │ │210 + offsetLeft: Number(el.dataset.scrollX || 0), │ │211 + top: el.scrollTop, │ │212 + left: el.scrollLeft, │ │213 + }); │ │214 } │ │116 - grid.innerHTML = html; │ │117 - for (const el of grid.querySelectorAll(".scroll[data-scroll-id]")) { │ │118 - const id = el.dataset.scrollId; │ │215 + patchHtml(grid, html); │ │216 + for (const el of grid.querySelectorAll(".scroll[data-key]")) { │ │217 const offset = { top: Number(el.dataset.scrollY || 0), left: Number(el.dataset.scrollX || 0) }; │ │120 - const prev = wasmOffsets[id]; │ │121 - const p = positions[id]; │ │122 - if (p && prev && prev.top === offset.top && prev.left === offset.left) { │ │123 - el.scrollTop = p.top; │ │124 - el.scrollLeft = p.left; │ │218 + const previous = scrolls.get(el.dataset.key); │ │219 + if (previous && previous.id === el.dataset.scrollId && previous.offsetTop === offset.top && previous.offsetLeft === offset.left) {│ │220 + el.scrollTop = previous.top; │ │221 + el.scrollLeft = previous.left; │ │222 } else { │ │223 // the wasm's offset changed (or this div is new — a fresh │ │224 // page load or a bumped content version): apply it, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────╮ │227 el.scrollTop = offset.top * cellHeight; │ │228 el.scrollLeft = offset.left * cellWidth;│ │229 } │ │133 - wasmOffsets[id] = offset; │ │230 } │ │231 }, │ │232 _replaceState: function (ptr, len) { │ ╰─────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │238 history.replaceState({}, "", url); │ │239 } │ │240 }, │ │145 - _setOverlay: function (ptr, len) { │ │146 - const html = readWasmString(ptr, len); │ │147 - // diff against the previous overlay HTML; an unchanged overlay │ │148 - // means no layout/structure has shifted (typing alone doesn't │ │149 - // produce a diff because we deliberately omit the `value` │ │150 - // attribute on inputs — the browser tracks that). skipping the │ │151 - // innerHTML assignment keeps the live <form> alive across the │ │152 - // mousedown→focusin→tick→click sequence, which is what lets │ │153 - // the very first click on the submit button actually submit. │ │154 - if (html === currentOverlay) return; │ │155 - // the initial wasm render can finish while a native picker is open. │ │156 - // keep its input connected so its change event reaches document. │ │157 - if (overlay.contains(document.activeElement) && document.activeElement.type === "file") return;│ │158 - currentOverlay = html; │ │159 - overlay.innerHTML = html; │ │160 - }, │ │241 _focusInput: function (id) { │ │162 - const target = overlay.querySelector(`[data-focus-id="${id}"]`) || │ │163 - grid.querySelector(`input[data-focus-id="${id}"]`); │ │242 + const target = grid.querySelector(`[data-key="control:${id}"]`); │ │243 if (!target) return; │ │244 if (document.activeElement !== target) target.focus(); │ │245 if (target.readOnly) target.select(); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯