wasm improvements

This commit is contained in:
Jorijn van der Graaf 2026-08-05 04:08:13 +02:00
commit 6aa0338c0f
9 changed files with 603 additions and 70 deletions

View file

@ -109,10 +109,60 @@ function setStyle(cookie, stylePtr, styleLen) {
const el = __jsmemory.get(cookie);
if (el) el.style.cssText = __readUtf8(stylePtr, styleLen);
}
// NOTE: setProperty is a CSS custom-property setter (el.style.setProperty),
// NOT setAttribute. The name is historical and misleading; the attribute
// functions below are the ones you want for href/src/disabled/aria-*.
function setProperty(cookie, propPtr, propLen, valPtr, valLen) {
const el = __jsmemory.get(cookie);
if (el) el.style.setProperty(__readUtf8(propPtr, propLen), __readUtf8(valPtr, valLen));
}
// Real attribute access. Without these the only way to change an href, src,
// disabled or aria-* was to re-render the parent's innerHTML, which destroys
// every descendant (and their event listeners) to change one string.
function setAttribute(cookie, namePtr, nameLen, valPtr, valLen) {
const el = __jsmemory.get(cookie);
if (el) el.setAttribute(__readUtf8(namePtr, nameLen), __readUtf8(valPtr, valLen));
}
function removeAttribute(cookie, namePtr, nameLen) {
const el = __jsmemory.get(cookie);
if (el) el.removeAttribute(__readUtf8(namePtr, nameLen));
}
// Returns 0 for "absent" so the caller can tell it apart from an attribute
// that is present with an empty value (`disabled=""`, `alt=""`) — which
// matters because for boolean attributes presence alone is the signal.
function getAttribute(cookie, namePtr, nameLen) {
const el = __jsmemory.get(cookie);
if (!el) return 0;
const v = el.getAttribute(__readUtf8(namePtr, nameLen));
if (v === null) return 0;
return __writeUtf8(v);
}
function hasAttribute(cookie, namePtr, nameLen) {
const el = __jsmemory.get(cookie);
if (!el) return false;
return el.hasAttribute(__readUtf8(namePtr, nameLen));
}
// Checkbox / radio state. el.value on a checkbox returns "on" regardless of
// whether it is ticked, so GetValue cannot express this.
function getChecked(cookie) {
const el = __jsmemory.get(cookie);
return !!(el && el.checked);
}
function setChecked(cookie, checked) {
const el = __jsmemory.get(cookie);
if (el) el.checked = !!checked;
}
// Move keyboard focus. AddFocusListener could observe focus but nothing
// could set it, which makes an error summary ("jump to the first invalid
// field") impossible to implement accessibly.
function focusElement(cookie) {
const el = __jsmemory.get(cookie);
if (el && el.focus) el.focus();
}
function blurElement(cookie) {
const el = __jsmemory.get(cookie);
if (el && el.blur) el.blur();
}
function addClass(cookie, namePtr, nameLen) {
const el = __jsmemory.get(cookie);
if (el) el.classList.add(__readUtf8(namePtr, nameLen));
@ -161,12 +211,25 @@ function __dpr() {
return window.crafter_dpr || window.devicePixelRatio || 1;
}
// `preventDefault` is opt-in per listener (the C++ Add*Listener overloads
// take it as a trailing argument). Two reasons it has to exist here rather
// than being something C++ can do after the fact:
// * the wasm callback runs synchronously inside the dispatch, but the
// decision has to be made before the browser acts on the event, and
// there is no handle to the event object on the C++ side;
// * intercepting a click on a real <a href> — the whole basis of
// client-side routing over crawlable links — is impossible without it.
// Cancelling happens BEFORE the wasm call so a trap or exception inside the
// handler can't leave the default action to fire anyway.
// `{ passive: false }` is required or the browser silently ignores
// preventDefault on wheel (and warns); harmless on the other kinds.
function __makeMouseListenerPair(kind, eventName, exportName) {
return {
add(cookie, id) {
add(cookie, id, preventDefault) {
const el = __jsmemory.get(cookie);
if (!el) return;
const handler = (event) => {
if (preventDefault) event.preventDefault();
const s = __dpr();
__wasm()[exportName](id,
event.clientX * s, event.clientY * s,
@ -175,7 +238,7 @@ function __makeMouseListenerPair(kind, eventName, exportName) {
event.altKey, event.ctrlKey, event.shiftKey, event.metaKey);
};
__listenerHandlers.set(`${cookie}-${id}-${kind}`, handler);
el.addEventListener(eventName, handler);
el.addEventListener(eventName, handler, preventDefault ? { passive: false } : undefined);
},
remove(cookie, id) {
const el = __jsmemory.get(cookie);
@ -188,17 +251,18 @@ function __makeMouseListenerPair(kind, eventName, exportName) {
}
function __makeKeyListenerPair(kind, eventName, exportName) {
return {
add(cookie, id) {
add(cookie, id, preventDefault) {
const el = __jsmemory.get(cookie);
if (!el) return;
const handler = (event) => {
if (preventDefault) event.preventDefault();
const keyPtr = __writeUtf8(event.key || "");
__wasm()[exportName](id, keyPtr, event.keyCode,
event.altKey, event.ctrlKey, event.shiftKey, event.metaKey);
__wasm().WasmFree(keyPtr);
};
__listenerHandlers.set(`${cookie}-${id}-${kind}`, handler);
el.addEventListener(eventName, handler);
el.addEventListener(eventName, handler, preventDefault ? { passive: false } : undefined);
},
remove(cookie, id) {
const el = __jsmemory.get(cookie);
@ -339,9 +403,10 @@ const __scrollPair = {
}
};
const __wheelPair = {
add(cookie, id) {
add(cookie, id, preventDefault) {
const el = __jsmemory.get(cookie); if (!el) return;
const handler = (event) => {
if (preventDefault) event.preventDefault();
const s = __dpr();
__wasm().ExecuteWheelHandler(id,
event.deltaX, event.deltaY, event.deltaZ, event.deltaMode,
@ -350,7 +415,10 @@ const __wheelPair = {
event.altKey, event.ctrlKey, event.shiftKey, event.metaKey);
};
__listenerHandlers.set(`${cookie}-${id}-wheel`, handler);
el.addEventListener("wheel", handler);
// passive:false is mandatory here — Chrome treats wheel as passive by
// default and would otherwise drop the preventDefault with a console
// warning rather than an error.
el.addEventListener("wheel", handler, preventDefault ? { passive: false } : undefined);
},
remove(cookie, id) {
const el = __jsmemory.get(cookie);
@ -619,9 +687,45 @@ function removePopStateListener(id) {
if (h) window.removeEventListener("popstate", h);
__listenerHandlers.delete(`popstate-${id}`);
}
// replaceState mirrors pushState but rewrites the current history entry
// instead of adding one. Needed for anything that changes the URL without
// being a distinct "back" destination — filter/sort state, canonicalising a
// sloppy incoming URL, or recording scroll position on the entry you are
// about to leave.
function replaceState(dataPtr, dataLen, titlePtr, titleLen, urlPtr, urlLen) {
const dataStr = __readUtf8(dataPtr, dataLen);
const titleStr = __readUtf8(titlePtr, titleLen);
const urlStr = __readUtf8(urlPtr, urlLen);
let parsed;
try { parsed = JSON.parse(dataStr); } catch { parsed = null; }
window.history.replaceState(parsed, titleStr, urlStr);
}
function getPathName() {
return __writeUtf8(window.location.pathname);
}
// location.search / .hash, including their leading "?" / "#" so an empty
// query and "?" are distinguishable, and so the result can be concatenated
// straight back onto a path. pathname alone left query strings completely
// unreachable from wasm, which made ?page= / ?sort= impossible to read.
function getSearch() {
return __writeUtf8(window.location.search);
}
function getHash() {
return __writeUtf8(window.location.hash);
}
function getHref() {
return __writeUtf8(window.location.href);
}
// Full-page navigation, including cross-origin. pushState deliberately
// cannot leave the origin, so this is the only way for a wasm app to hand
// the user off to an external URL (an OAuth consent screen, a hosted
// payment page). `replace` omits the current page from history so Back
// doesn't return to a stale checkout.
function navigate(urlPtr, urlLen, replace) {
const url = __readUtf8(urlPtr, urlLen);
if (replace) window.location.replace(url);
else window.location.assign(url);
}
// ─── Gamepad polling helper ───────────────────────────────────────────
//
@ -659,6 +763,8 @@ Object.assign(window.crafter_webbuild_env, {
// DOM lookup / creation / mutation
freeJs, getElementById, createElement, getBody,
setInnerHTML, setStyle, setProperty,
setAttribute, removeAttribute, getAttribute, hasAttribute,
getChecked, setChecked, focusElement, blurElement,
addClass, removeClass, toggleClass, hasClass,
deleteElement, getValue, setValue,
@ -697,7 +803,8 @@ Object.assign(window.crafter_webbuild_env, {
clipboardSetText, clipboardGetText,
// History
pushState, addPopStateListener, removePopStateListener, getPathName,
pushState, replaceState, addPopStateListener, removePopStateListener,
getPathName, getSearch, getHash, getHref, navigate,
// Gamepad
gamepadPollConnected, gamepadPollDisconnected,