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); const el = __jsmemory.get(cookie);
if (el) el.style.cssText = __readUtf8(stylePtr, styleLen); 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) { function setProperty(cookie, propPtr, propLen, valPtr, valLen) {
const el = __jsmemory.get(cookie); const el = __jsmemory.get(cookie);
if (el) el.style.setProperty(__readUtf8(propPtr, propLen), __readUtf8(valPtr, valLen)); 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) { function addClass(cookie, namePtr, nameLen) {
const el = __jsmemory.get(cookie); const el = __jsmemory.get(cookie);
if (el) el.classList.add(__readUtf8(namePtr, nameLen)); if (el) el.classList.add(__readUtf8(namePtr, nameLen));
@ -161,12 +211,25 @@ function __dpr() {
return window.crafter_dpr || window.devicePixelRatio || 1; 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) { function __makeMouseListenerPair(kind, eventName, exportName) {
return { return {
add(cookie, id) { add(cookie, id, preventDefault) {
const el = __jsmemory.get(cookie); const el = __jsmemory.get(cookie);
if (!el) return; if (!el) return;
const handler = (event) => { const handler = (event) => {
if (preventDefault) event.preventDefault();
const s = __dpr(); const s = __dpr();
__wasm()[exportName](id, __wasm()[exportName](id,
event.clientX * s, event.clientY * s, event.clientX * s, event.clientY * s,
@ -175,7 +238,7 @@ function __makeMouseListenerPair(kind, eventName, exportName) {
event.altKey, event.ctrlKey, event.shiftKey, event.metaKey); event.altKey, event.ctrlKey, event.shiftKey, event.metaKey);
}; };
__listenerHandlers.set(`${cookie}-${id}-${kind}`, handler); __listenerHandlers.set(`${cookie}-${id}-${kind}`, handler);
el.addEventListener(eventName, handler); el.addEventListener(eventName, handler, preventDefault ? { passive: false } : undefined);
}, },
remove(cookie, id) { remove(cookie, id) {
const el = __jsmemory.get(cookie); const el = __jsmemory.get(cookie);
@ -188,17 +251,18 @@ function __makeMouseListenerPair(kind, eventName, exportName) {
} }
function __makeKeyListenerPair(kind, eventName, exportName) { function __makeKeyListenerPair(kind, eventName, exportName) {
return { return {
add(cookie, id) { add(cookie, id, preventDefault) {
const el = __jsmemory.get(cookie); const el = __jsmemory.get(cookie);
if (!el) return; if (!el) return;
const handler = (event) => { const handler = (event) => {
if (preventDefault) event.preventDefault();
const keyPtr = __writeUtf8(event.key || ""); const keyPtr = __writeUtf8(event.key || "");
__wasm()[exportName](id, keyPtr, event.keyCode, __wasm()[exportName](id, keyPtr, event.keyCode,
event.altKey, event.ctrlKey, event.shiftKey, event.metaKey); event.altKey, event.ctrlKey, event.shiftKey, event.metaKey);
__wasm().WasmFree(keyPtr); __wasm().WasmFree(keyPtr);
}; };
__listenerHandlers.set(`${cookie}-${id}-${kind}`, handler); __listenerHandlers.set(`${cookie}-${id}-${kind}`, handler);
el.addEventListener(eventName, handler); el.addEventListener(eventName, handler, preventDefault ? { passive: false } : undefined);
}, },
remove(cookie, id) { remove(cookie, id) {
const el = __jsmemory.get(cookie); const el = __jsmemory.get(cookie);
@ -339,9 +403,10 @@ const __scrollPair = {
} }
}; };
const __wheelPair = { const __wheelPair = {
add(cookie, id) { add(cookie, id, preventDefault) {
const el = __jsmemory.get(cookie); if (!el) return; const el = __jsmemory.get(cookie); if (!el) return;
const handler = (event) => { const handler = (event) => {
if (preventDefault) event.preventDefault();
const s = __dpr(); const s = __dpr();
__wasm().ExecuteWheelHandler(id, __wasm().ExecuteWheelHandler(id,
event.deltaX, event.deltaY, event.deltaZ, event.deltaMode, event.deltaX, event.deltaY, event.deltaZ, event.deltaMode,
@ -350,7 +415,10 @@ const __wheelPair = {
event.altKey, event.ctrlKey, event.shiftKey, event.metaKey); event.altKey, event.ctrlKey, event.shiftKey, event.metaKey);
}; };
__listenerHandlers.set(`${cookie}-${id}-wheel`, handler); __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) { remove(cookie, id) {
const el = __jsmemory.get(cookie); const el = __jsmemory.get(cookie);
@ -619,9 +687,45 @@ function removePopStateListener(id) {
if (h) window.removeEventListener("popstate", h); if (h) window.removeEventListener("popstate", h);
__listenerHandlers.delete(`popstate-${id}`); __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() { function getPathName() {
return __writeUtf8(window.location.pathname); 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 ─────────────────────────────────────────── // ─── Gamepad polling helper ───────────────────────────────────────────
// //
@ -659,6 +763,8 @@ Object.assign(window.crafter_webbuild_env, {
// DOM lookup / creation / mutation // DOM lookup / creation / mutation
freeJs, getElementById, createElement, getBody, freeJs, getElementById, createElement, getBody,
setInnerHTML, setStyle, setProperty, setInnerHTML, setStyle, setProperty,
setAttribute, removeAttribute, getAttribute, hasAttribute,
getChecked, setChecked, focusElement, blurElement,
addClass, removeClass, toggleClass, hasClass, addClass, removeClass, toggleClass, hasClass,
deleteElement, getValue, setValue, deleteElement, getValue, setValue,
@ -697,7 +803,8 @@ Object.assign(window.crafter_webbuild_env, {
clipboardSetText, clipboardGetText, clipboardSetText, clipboardGetText,
// History // History
pushState, addPopStateListener, removePopStateListener, getPathName, pushState, replaceState, addPopStateListener, removePopStateListener,
getPathName, getSearch, getHash, getHref, navigate,
// Gamepad // Gamepad
gamepadPollConnected, gamepadPollDisconnected, gamepadPollConnected, gamepadPollDisconnected,

View file

@ -94,6 +94,16 @@ document.body.appendChild(canvas);
// when set, the canvas is reparented into that element and sized to it. // when set, the canvas is reparented into that element and sized to it.
let mountEl = null; let mountEl = null;
// Upper bound on the canvas backing store, in pixels. Infinity until an RT
// dispatch derives the real budget from the device's buffer limits (see
// wgpuDispatchRT): the wavefront ray buffers scale with W·H·raysPerPixel,
// and a 256 MiB maxBufferSize (Firefox's baseline) overflows at ~0.5 Mpx
// for 4 rays/pixel — far below a HiDPI 16:9 mount — failing not at the
// createBuffer call but as an uncapturable device OOM at submit. The
// mounted canvas is CSS-pinned to width:100%/height:100%, so a clamped
// backing store upscales on screen instead of shrinking.
let maxCanvasPixels = Infinity;
function syncCanvasSize() { function syncCanvasSize() {
// Canvas pixel size = CSS size × devicePixelRatio so the GPU draws // Canvas pixel size = CSS size × devicePixelRatio so the GPU draws
// at physical pixel resolution on HiDPI displays — otherwise the // at physical pixel resolution on HiDPI displays — otherwise the
@ -117,8 +127,13 @@ function syncCanvasSize() {
cssW = window.innerWidth; cssW = window.innerWidth;
cssH = window.innerHeight; cssH = window.innerHeight;
} }
const w = Math.max(1, Math.round(cssW * dpr)); let w = Math.max(1, Math.round(cssW * dpr));
const h = Math.max(1, Math.round(cssH * dpr)); let h = Math.max(1, Math.round(cssH * dpr));
if (w * h > maxCanvasPixels) {
const s = Math.sqrt(maxCanvasPixels / (w * h));
w = Math.max(1, Math.floor(w * s));
h = Math.max(1, Math.floor(h * s));
}
if (canvas.width !== w) canvas.width = w; if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h; if (canvas.height !== h) canvas.height = h;
return { w, h }; return { w, h };
@ -3511,6 +3526,28 @@ env.wgpuDispatchRT = (pipelineHandle, pushPtr, pushBytes,
} }
const W = state.width, H = state.height; const W = state.width, H = state.height;
const depth = Math.max(1, maxDepth | 0); const depth = Math.max(1, maxDepth | 0);
// Clamp the render resolution to what the wavefront buffers can hold.
// The payload store is the largest per-ray cost (2 regions ×
// WF_PAYLOAD_BYTES) and must fit both maxBufferSize and one storage
// binding; the indirect TRACE/SHADE dispatch additionally bounds rays
// at maxComputeWorkgroupsPerDimension×64. When the canvas is over
// budget, tighten maxCanvasPixels and sit this frame out — the next
// wgpuFrameBegin's ensureSized() shrinks the backing store (CSS keeps
// the on-screen size) and rendering resumes within budget.
const rpp = Math.max(1, raysPerPixel | 0);
const rayBudget = Math.min(
Math.floor(Math.min(device.limits.maxBufferSize,
device.limits.maxStorageBufferBindingSize)
/ (2 * WF_PAYLOAD_BYTES)),
device.limits.maxComputeWorkgroupsPerDimension * 64);
const pixelBudget = Math.max(1, Math.floor(rayBudget / rpp));
if (pixelBudget < maxCanvasPixels) maxCanvasPixels = pixelBudget;
if (W * H > maxCanvasPixels) {
console.warn(`[crafter-wgpu] ${W}x${H} at ${rpp} rays/px overflows the `
+ `wavefront budget (maxBufferSize ${device.limits.maxBufferSize}); `
+ `reducing render resolution to ~${maxCanvasPixels}px`);
return;
}
const wf = ensureWavefrontBuffers(W, H, raysPerPixel); const wf = ensureWavefrontBuffers(W, H, raysPerPixel);
const cap = wf.cap; // per-bounce ray capacity = raysPerPixel·W·H const cap = wf.cap; // per-bounce ray capacity = raysPerPixel·W·H

View file

@ -41,6 +41,24 @@ namespace Crafter::DomBindings {
void SetProperty(std::int32_t ptr, void SetProperty(std::int32_t ptr,
const char* property, std::int32_t propertyLength, const char* property, std::int32_t propertyLength,
const char* value, std::int32_t valueLength); const char* value, std::int32_t valueLength);
__attribute__((import_module("env"), import_name("setAttribute")))
void SetAttribute(std::int32_t ptr,
const char* name, std::int32_t nameLength,
const char* value, std::int32_t valueLength);
__attribute__((import_module("env"), import_name("removeAttribute")))
void RemoveAttribute(std::int32_t ptr, const char* name, std::int32_t nameLength);
__attribute__((import_module("env"), import_name("getAttribute")))
const char* GetAttribute(std::int32_t ptr, const char* name, std::int32_t nameLength);
__attribute__((import_module("env"), import_name("hasAttribute")))
bool HasAttribute(std::int32_t ptr, const char* name, std::int32_t nameLength);
__attribute__((import_module("env"), import_name("getChecked")))
bool GetChecked(std::int32_t ptr);
__attribute__((import_module("env"), import_name("setChecked")))
void SetChecked(std::int32_t ptr, bool checked);
__attribute__((import_module("env"), import_name("focusElement")))
void FocusElement(std::int32_t ptr);
__attribute__((import_module("env"), import_name("blurElement")))
void BlurElement(std::int32_t ptr);
__attribute__((import_module("env"), import_name("addClass"))) __attribute__((import_module("env"), import_name("addClass")))
void AddClass(std::int32_t ptr, const char* className, std::int32_t classNameLength); void AddClass(std::int32_t ptr, const char* className, std::int32_t classNameLength);
__attribute__((import_module("env"), import_name("removeClass"))) __attribute__((import_module("env"), import_name("removeClass")))
@ -57,37 +75,54 @@ namespace Crafter::DomBindings {
void SetValue(std::int32_t ptr, const char* value, std::int32_t valueLength); void SetValue(std::int32_t ptr, const char* value, std::int32_t valueLength);
// Per-event-kind listener register / unregister imports. // Per-event-kind listener register / unregister imports.
//
// Two flavours, because only some DOM events are cancelable:
// CG_DOM_LISTENER_IMPORT_CANCELABLE — mouse, key and wheel kinds. The
// add takes a trailing `preventDefault` the JS side applies before
// dispatching into wasm (see additional/dom-env.js).
// CG_DOM_LISTENER_IMPORT — everything else. focus/blur/change/input/
// resize/scroll are not cancelable, so a flag there would be a
// silent no-op and is better left off the API. `submit` is the odd
// one out: its JS handler ALWAYS calls preventDefault (a native form
// POST would navigate away from the wasm app), so it has no flag and
// no way to opt out.
#define CG_DOM_LISTENER_IMPORT(addName, removeName) \ #define CG_DOM_LISTENER_IMPORT(addName, removeName) \
__attribute__((import_module("env"), import_name(#addName))) \ __attribute__((import_module("env"), import_name(#addName))) \
void addName(std::int32_t ptr, std::int32_t id); \ void addName(std::int32_t ptr, std::int32_t id); \
__attribute__((import_module("env"), import_name(#removeName))) \ __attribute__((import_module("env"), import_name(#removeName))) \
void removeName(std::int32_t ptr, std::int32_t id); void removeName(std::int32_t ptr, std::int32_t id);
#define CG_DOM_LISTENER_IMPORT_CANCELABLE(addName, removeName) \
__attribute__((import_module("env"), import_name(#addName))) \
void addName(std::int32_t ptr, std::int32_t id, bool preventDefault); \
__attribute__((import_module("env"), import_name(#removeName))) \
void removeName(std::int32_t ptr, std::int32_t id);
CG_DOM_LISTENER_IMPORT(addClickListener, removeClickListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addClickListener, removeClickListener)
CG_DOM_LISTENER_IMPORT(addMouseOverListener, removeMouseOverListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addMouseOverListener, removeMouseOverListener)
CG_DOM_LISTENER_IMPORT(addMouseOutListener, removeMouseOutListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addMouseOutListener, removeMouseOutListener)
CG_DOM_LISTENER_IMPORT(addMouseMoveListener, removeMouseMoveListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addMouseMoveListener, removeMouseMoveListener)
CG_DOM_LISTENER_IMPORT(addMouseDownListener, removeMouseDownListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addMouseDownListener, removeMouseDownListener)
CG_DOM_LISTENER_IMPORT(addMouseUpListener, removeMouseUpListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addMouseUpListener, removeMouseUpListener)
CG_DOM_LISTENER_IMPORT(addFocusListener, removeFocusListener) CG_DOM_LISTENER_IMPORT(addFocusListener, removeFocusListener)
CG_DOM_LISTENER_IMPORT(addBlurListener, removeBlurListener) CG_DOM_LISTENER_IMPORT(addBlurListener, removeBlurListener)
CG_DOM_LISTENER_IMPORT(addKeyDownListener, removeKeyDownListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addKeyDownListener, removeKeyDownListener)
CG_DOM_LISTENER_IMPORT(addKeyUpListener, removeKeyUpListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addKeyUpListener, removeKeyUpListener)
CG_DOM_LISTENER_IMPORT(addKeyPressListener, removeKeyPressListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addKeyPressListener, removeKeyPressListener)
CG_DOM_LISTENER_IMPORT(addChangeListener, removeChangeListener) CG_DOM_LISTENER_IMPORT(addChangeListener, removeChangeListener)
CG_DOM_LISTENER_IMPORT(addSubmitListener, removeSubmitListener) CG_DOM_LISTENER_IMPORT(addSubmitListener, removeSubmitListener)
CG_DOM_LISTENER_IMPORT(addInputListener, removeInputListener) CG_DOM_LISTENER_IMPORT(addInputListener, removeInputListener)
CG_DOM_LISTENER_IMPORT(addResizeListener, removeResizeListener) CG_DOM_LISTENER_IMPORT(addResizeListener, removeResizeListener)
CG_DOM_LISTENER_IMPORT(addScrollListener, removeScrollListener) CG_DOM_LISTENER_IMPORT(addScrollListener, removeScrollListener)
CG_DOM_LISTENER_IMPORT(addContextMenuListener, removeContextMenuListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addContextMenuListener, removeContextMenuListener)
CG_DOM_LISTENER_IMPORT(addDragStartListener, removeDragStartListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addDragStartListener, removeDragStartListener)
CG_DOM_LISTENER_IMPORT(addDragEndListener, removeDragEndListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addDragEndListener, removeDragEndListener)
CG_DOM_LISTENER_IMPORT(addDropListener, removeDropListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addDropListener, removeDropListener)
CG_DOM_LISTENER_IMPORT(addDragOverListener, removeDragOverListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addDragOverListener, removeDragOverListener)
CG_DOM_LISTENER_IMPORT(addDragEnterListener, removeDragEnterListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addDragEnterListener, removeDragEnterListener)
CG_DOM_LISTENER_IMPORT(addDragLeaveListener, removeDragLeaveListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addDragLeaveListener, removeDragLeaveListener)
CG_DOM_LISTENER_IMPORT(addWheelListener, removeWheelListener) CG_DOM_LISTENER_IMPORT_CANCELABLE(addWheelListener, removeWheelListener)
#undef CG_DOM_LISTENER_IMPORT #undef CG_DOM_LISTENER_IMPORT
#undef CG_DOM_LISTENER_IMPORT_CANCELABLE
// Per-event-kind callback maps. Counters are per-kind so two // Per-event-kind callback maps. Counters are per-kind so two
// different event kinds can share an id without aliasing — the JS // different event kinds can share an id without aliasing — the JS
@ -272,6 +307,23 @@ namespace {
trackingList.push_back(id); trackingList.push_back(id);
return id; return id;
} }
// As AddImpl, for the cancelable kinds whose JS add takes the extra
// preventDefault flag. Separate overload rather than a defaulted
// parameter so passing the flag to a non-cancelable kind doesn't compile.
template <typename EvT, typename JsAdd>
std::int32_t AddCancelableImpl(Crafter::DomBindings::HandlerTable<EvT>& table,
std::int32_t ptr,
std::vector<std::int32_t>& trackingList,
JsAdd jsAdd,
std::function<void(EvT)> callback,
bool preventDefault) {
if (ptr == 0) return 0;
std::int32_t id = table.maxId++;
table.map.insert({id, std::move(callback)});
jsAdd(ptr, id, preventDefault);
trackingList.push_back(id);
return id;
}
template <typename JsAdd> template <typename JsAdd>
std::int32_t AddVoidImpl(Crafter::DomBindings::HandlerTable<void>& table, std::int32_t AddVoidImpl(Crafter::DomBindings::HandlerTable<void>& table,
std::int32_t ptr, std::int32_t ptr,
@ -412,6 +464,42 @@ namespace Crafter::Dom {
if (ptr) Crafter::DomBindings::SetValue(ptr, value.data(), value.size()); if (ptr) Crafter::DomBindings::SetValue(ptr, value.data(), value.size());
} }
void HtmlElementPtr::SetAttribute(const std::string_view name,
const std::string_view value) {
if (ptr) Crafter::DomBindings::SetAttribute(ptr, name.data(), name.size(),
value.data(), value.size());
}
void HtmlElementPtr::RemoveAttribute(const std::string_view name) {
if (ptr) Crafter::DomBindings::RemoveAttribute(ptr, name.data(), name.size());
}
std::optional<std::string> HtmlElementPtr::GetAttribute(const std::string_view name) {
if (!ptr) return std::nullopt;
const char* raw = Crafter::DomBindings::GetAttribute(ptr, name.data(), name.size());
// 0 means the attribute is absent, which is distinct from present-but-
// empty — for boolean attributes presence alone is the signal.
if (!raw) return std::nullopt;
std::string out(raw);
std::free(const_cast<char*>(raw));
return out;
}
bool HtmlElementPtr::HasAttribute(const std::string_view name) {
if (!ptr) return false;
return Crafter::DomBindings::HasAttribute(ptr, name.data(), name.size());
}
bool HtmlElementPtr::GetChecked() {
if (!ptr) return false;
return Crafter::DomBindings::GetChecked(ptr);
}
void HtmlElementPtr::SetChecked(bool checked) {
if (ptr) Crafter::DomBindings::SetChecked(ptr, checked);
}
void HtmlElementPtr::Focus() {
if (ptr) Crafter::DomBindings::FocusElement(ptr);
}
void HtmlElementPtr::Blur() {
if (ptr) Crafter::DomBindings::BlurElement(ptr);
}
// Listener wrappers. Each Add/Remove pair just plugs the right // Listener wrappers. Each Add/Remove pair just plugs the right
// table, kind index, and js import into the helper templates. The // table, kind index, and js import into the helper templates. The
// 23 (+1 for popstate, lived in :Router) listener kinds previously // 23 (+1 for popstate, lived in :Router) listener kinds previously
@ -423,6 +511,13 @@ namespace Crafter::Dom {
handlerIds_[(std::size_t)Crafter::DomBindings::Kind::KindEnum], \ handlerIds_[(std::size_t)Crafter::DomBindings::Kind::KindEnum], \
Crafter::DomBindings::JsAdd, std::move(cb)); \ Crafter::DomBindings::JsAdd, std::move(cb)); \
} }
#define CG_DOM_ADD_CANCELABLE(MethodName, JsAdd, TableName, EvType, KindEnum) \
std::int32_t HtmlElementPtr::MethodName(std::function<void(EvType)> cb, \
bool preventDefault) { \
return AddCancelableImpl<EvType>(Crafter::DomBindings::TableName, ptr, \
handlerIds_[(std::size_t)Crafter::DomBindings::Kind::KindEnum], \
Crafter::DomBindings::JsAdd, std::move(cb), preventDefault); \
}
#define CG_DOM_REMOVE(MethodName, JsRemove, TableName, KindEnum) \ #define CG_DOM_REMOVE(MethodName, JsRemove, TableName, KindEnum) \
void HtmlElementPtr::MethodName(std::int32_t id) { \ void HtmlElementPtr::MethodName(std::int32_t id) { \
RemoveImpl(Crafter::DomBindings::TableName, ptr, \ RemoveImpl(Crafter::DomBindings::TableName, ptr, \
@ -430,27 +525,27 @@ namespace Crafter::Dom {
Crafter::DomBindings::JsRemove, id); \ Crafter::DomBindings::JsRemove, id); \
} }
CG_DOM_ADD (AddClickListener, addClickListener, clickT, MouseEvent, Click) CG_DOM_ADD_CANCELABLE(AddClickListener, addClickListener, clickT, MouseEvent, Click)
CG_DOM_REMOVE(RemoveClickListener, removeClickListener, clickT, Click) CG_DOM_REMOVE(RemoveClickListener, removeClickListener, clickT, Click)
CG_DOM_ADD (AddMouseOverListener, addMouseOverListener, mouseOverT, MouseEvent, MouseOver) CG_DOM_ADD_CANCELABLE(AddMouseOverListener, addMouseOverListener, mouseOverT, MouseEvent, MouseOver)
CG_DOM_REMOVE(RemoveMouseOverListener, removeMouseOverListener, mouseOverT, MouseOver) CG_DOM_REMOVE(RemoveMouseOverListener, removeMouseOverListener, mouseOverT, MouseOver)
CG_DOM_ADD (AddMouseOutListener, addMouseOutListener, mouseOutT, MouseEvent, MouseOut) CG_DOM_ADD_CANCELABLE(AddMouseOutListener, addMouseOutListener, mouseOutT, MouseEvent, MouseOut)
CG_DOM_REMOVE(RemoveMouseOutListener, removeMouseOutListener, mouseOutT, MouseOut) CG_DOM_REMOVE(RemoveMouseOutListener, removeMouseOutListener, mouseOutT, MouseOut)
CG_DOM_ADD (AddMouseMoveListener, addMouseMoveListener, mouseMoveT, MouseEvent, MouseMove) CG_DOM_ADD_CANCELABLE(AddMouseMoveListener, addMouseMoveListener, mouseMoveT, MouseEvent, MouseMove)
CG_DOM_REMOVE(RemoveMouseMoveListener, removeMouseMoveListener, mouseMoveT, MouseMove) CG_DOM_REMOVE(RemoveMouseMoveListener, removeMouseMoveListener, mouseMoveT, MouseMove)
CG_DOM_ADD (AddMouseDownListener, addMouseDownListener, mouseDownT, MouseEvent, MouseDown) CG_DOM_ADD_CANCELABLE(AddMouseDownListener, addMouseDownListener, mouseDownT, MouseEvent, MouseDown)
CG_DOM_REMOVE(RemoveMouseDownListener, removeMouseDownListener, mouseDownT, MouseDown) CG_DOM_REMOVE(RemoveMouseDownListener, removeMouseDownListener, mouseDownT, MouseDown)
CG_DOM_ADD (AddMouseUpListener, addMouseUpListener, mouseUpT, MouseEvent, MouseUp) CG_DOM_ADD_CANCELABLE(AddMouseUpListener, addMouseUpListener, mouseUpT, MouseEvent, MouseUp)
CG_DOM_REMOVE(RemoveMouseUpListener, removeMouseUpListener, mouseUpT, MouseUp) CG_DOM_REMOVE(RemoveMouseUpListener, removeMouseUpListener, mouseUpT, MouseUp)
CG_DOM_ADD (AddFocusListener, addFocusListener, focusT, FocusEvent, Focus) CG_DOM_ADD (AddFocusListener, addFocusListener, focusT, FocusEvent, Focus)
CG_DOM_REMOVE(RemoveFocusListener, removeFocusListener, focusT, Focus) CG_DOM_REMOVE(RemoveFocusListener, removeFocusListener, focusT, Focus)
CG_DOM_ADD (AddBlurListener, addBlurListener, blurT, FocusEvent, Blur) CG_DOM_ADD (AddBlurListener, addBlurListener, blurT, FocusEvent, Blur)
CG_DOM_REMOVE(RemoveBlurListener, removeBlurListener, blurT, Blur) CG_DOM_REMOVE(RemoveBlurListener, removeBlurListener, blurT, Blur)
CG_DOM_ADD (AddKeyDownListener, addKeyDownListener, keyDownT, KeyboardEvent, KeyDown) CG_DOM_ADD_CANCELABLE(AddKeyDownListener, addKeyDownListener, keyDownT, KeyboardEvent, KeyDown)
CG_DOM_REMOVE(RemoveKeyDownListener, removeKeyDownListener, keyDownT, KeyDown) CG_DOM_REMOVE(RemoveKeyDownListener, removeKeyDownListener, keyDownT, KeyDown)
CG_DOM_ADD (AddKeyUpListener, addKeyUpListener, keyUpT, KeyboardEvent, KeyUp) CG_DOM_ADD_CANCELABLE(AddKeyUpListener, addKeyUpListener, keyUpT, KeyboardEvent, KeyUp)
CG_DOM_REMOVE(RemoveKeyUpListener, removeKeyUpListener, keyUpT, KeyUp) CG_DOM_REMOVE(RemoveKeyUpListener, removeKeyUpListener, keyUpT, KeyUp)
CG_DOM_ADD (AddKeyPressListener, addKeyPressListener, keyPressT, KeyboardEvent, KeyPress) CG_DOM_ADD_CANCELABLE(AddKeyPressListener, addKeyPressListener, keyPressT, KeyboardEvent, KeyPress)
CG_DOM_REMOVE(RemoveKeyPressListener, removeKeyPressListener, keyPressT, KeyPress) CG_DOM_REMOVE(RemoveKeyPressListener, removeKeyPressListener, keyPressT, KeyPress)
CG_DOM_ADD (AddChangeListener, addChangeListener, changeT, ChangeEvent, Change) CG_DOM_ADD (AddChangeListener, addChangeListener, changeT, ChangeEvent, Change)
CG_DOM_REMOVE(RemoveChangeListener, removeChangeListener, changeT, Change) CG_DOM_REMOVE(RemoveChangeListener, removeChangeListener, changeT, Change)
@ -460,21 +555,21 @@ namespace Crafter::Dom {
CG_DOM_REMOVE(RemoveResizeListener, removeResizeListener, resizeT, Resize) CG_DOM_REMOVE(RemoveResizeListener, removeResizeListener, resizeT, Resize)
CG_DOM_ADD (AddScrollListener, addScrollListener, scrollT, ScrollEvent, Scroll) CG_DOM_ADD (AddScrollListener, addScrollListener, scrollT, ScrollEvent, Scroll)
CG_DOM_REMOVE(RemoveScrollListener, removeScrollListener, scrollT, Scroll) CG_DOM_REMOVE(RemoveScrollListener, removeScrollListener, scrollT, Scroll)
CG_DOM_ADD (AddContextMenuListener, addContextMenuListener, contextMenuT, MouseEvent, ContextMenu) CG_DOM_ADD_CANCELABLE(AddContextMenuListener, addContextMenuListener, contextMenuT, MouseEvent, ContextMenu)
CG_DOM_REMOVE(RemoveContextMenuListener,removeContextMenuListener,contextMenuT, ContextMenu) CG_DOM_REMOVE(RemoveContextMenuListener,removeContextMenuListener,contextMenuT, ContextMenu)
CG_DOM_ADD (AddDragStartListener, addDragStartListener, dragStartT, MouseEvent, DragStart) CG_DOM_ADD_CANCELABLE(AddDragStartListener, addDragStartListener, dragStartT, MouseEvent, DragStart)
CG_DOM_REMOVE(RemoveDragStartListener, removeDragStartListener, dragStartT, DragStart) CG_DOM_REMOVE(RemoveDragStartListener, removeDragStartListener, dragStartT, DragStart)
CG_DOM_ADD (AddDragEndListener, addDragEndListener, dragEndT, MouseEvent, DragEnd) CG_DOM_ADD_CANCELABLE(AddDragEndListener, addDragEndListener, dragEndT, MouseEvent, DragEnd)
CG_DOM_REMOVE(RemoveDragEndListener, removeDragEndListener, dragEndT, DragEnd) CG_DOM_REMOVE(RemoveDragEndListener, removeDragEndListener, dragEndT, DragEnd)
CG_DOM_ADD (AddDropListener, addDropListener, dropT, MouseEvent, Drop) CG_DOM_ADD_CANCELABLE(AddDropListener, addDropListener, dropT, MouseEvent, Drop)
CG_DOM_REMOVE(RemoveDropListener, removeDropListener, dropT, Drop) CG_DOM_REMOVE(RemoveDropListener, removeDropListener, dropT, Drop)
CG_DOM_ADD (AddDragOverListener, addDragOverListener, dragOverT, MouseEvent, DragOver) CG_DOM_ADD_CANCELABLE(AddDragOverListener, addDragOverListener, dragOverT, MouseEvent, DragOver)
CG_DOM_REMOVE(RemoveDragOverListener, removeDragOverListener, dragOverT, DragOver) CG_DOM_REMOVE(RemoveDragOverListener, removeDragOverListener, dragOverT, DragOver)
CG_DOM_ADD (AddDragEnterListener, addDragEnterListener, dragEnterT, MouseEvent, DragEnter) CG_DOM_ADD_CANCELABLE(AddDragEnterListener, addDragEnterListener, dragEnterT, MouseEvent, DragEnter)
CG_DOM_REMOVE(RemoveDragEnterListener, removeDragEnterListener, dragEnterT, DragEnter) CG_DOM_REMOVE(RemoveDragEnterListener, removeDragEnterListener, dragEnterT, DragEnter)
CG_DOM_ADD (AddDragLeaveListener, addDragLeaveListener, dragLeaveT, MouseEvent, DragLeave) CG_DOM_ADD_CANCELABLE(AddDragLeaveListener, addDragLeaveListener, dragLeaveT, MouseEvent, DragLeave)
CG_DOM_REMOVE(RemoveDragLeaveListener, removeDragLeaveListener, dragLeaveT, DragLeave) CG_DOM_REMOVE(RemoveDragLeaveListener, removeDragLeaveListener, dragLeaveT, DragLeave)
CG_DOM_ADD (AddWheelListener, addWheelListener, wheelT, WheelEvent, Wheel) CG_DOM_ADD_CANCELABLE(AddWheelListener, addWheelListener, wheelT, WheelEvent, Wheel)
CG_DOM_REMOVE(RemoveWheelListener, removeWheelListener, wheelT, Wheel) CG_DOM_REMOVE(RemoveWheelListener, removeWheelListener, wheelT, Wheel)
#undef CG_DOM_ADD #undef CG_DOM_ADD
#undef CG_DOM_REMOVE #undef CG_DOM_REMOVE

View file

@ -20,8 +20,20 @@ namespace Crafter::DomBindings {
void AddPopStateListener(std::int32_t id); void AddPopStateListener(std::int32_t id);
__attribute__((import_module("env"), import_name("removePopStateListener"))) __attribute__((import_module("env"), import_name("removePopStateListener")))
void RemovePopStateListener(std::int32_t id); void RemovePopStateListener(std::int32_t id);
__attribute__((import_module("env"), import_name("replaceState")))
void ReplaceState(const char* data, std::int32_t dataLength,
const char* title, std::int32_t titleLength,
const char* url, std::int32_t urlLength);
__attribute__((import_module("env"), import_name("getPathName"))) __attribute__((import_module("env"), import_name("getPathName")))
const char* GetPathName(); const char* GetPathName();
__attribute__((import_module("env"), import_name("getSearch")))
const char* GetSearch();
__attribute__((import_module("env"), import_name("getHash")))
const char* GetHash();
__attribute__((import_module("env"), import_name("getHref")))
const char* GetHref();
__attribute__((import_module("env"), import_name("navigate")))
void Navigate(const char* url, std::int32_t urlLength, bool replace);
// Defined in Crafter.Graphics-Dom.cpp. // Defined in Crafter.Graphics-Dom.cpp.
std::int32_t PopStateRegister(std::function<void()> cb); std::int32_t PopStateRegister(std::function<void()> cb);
@ -48,11 +60,114 @@ namespace Crafter::Router {
Crafter::DomBindings::PopStateUnregister(id); Crafter::DomBindings::PopStateUnregister(id);
} }
std::string GetPath() { void ReplaceState(std::string_view data, std::string_view title, std::string_view url) {
const char* raw = Crafter::DomBindings::GetPathName(); Crafter::DomBindings::ReplaceState(
data.data(), static_cast<std::int32_t>(data.size()),
title.data(), static_cast<std::int32_t>(title.size()),
url.data(), static_cast<std::int32_t>(url.size()));
}
// The four location accessors share the same shape: the JS side
// WasmAllocs a NUL-terminated UTF-8 copy, we adopt it into a
// std::string and free the raw buffer. A null return means the JS
// allocation failed.
namespace {
std::string AdoptJsString(const char* raw) {
if (!raw) return {}; if (!raw) return {};
std::string out(raw); std::string out(raw);
std::free(const_cast<char*>(raw)); std::free(const_cast<char*>(raw));
return out; return out;
} }
} }
std::string GetPath() {
return AdoptJsString(Crafter::DomBindings::GetPathName());
}
std::string GetSearch() {
return AdoptJsString(Crafter::DomBindings::GetSearch());
}
std::string GetHash() {
return AdoptJsString(Crafter::DomBindings::GetHash());
}
std::string GetHref() {
return AdoptJsString(Crafter::DomBindings::GetHref());
}
void Navigate(std::string_view url, bool replace) {
Crafter::DomBindings::Navigate(
url.data(), static_cast<std::int32_t>(url.size()), replace);
}
// ─── Query-string parsing ─────────────────────────────────────────
//
// Pure string work, no JS round-trip. Kept here rather than left to
// callers because every consumer would otherwise hand-roll percent
// decoding, and getting `+` vs `%20` wrong is the usual bug.
namespace {
int HexVal(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
}
std::string PercentDecode(std::string_view in) {
std::string out;
out.reserve(in.size());
for (std::size_t i = 0; i < in.size(); ++i) {
const char c = in[i];
if (c == '+') {
// application/x-www-form-urlencoded encodes space as '+'.
out.push_back(' ');
} else if (c == '%' && i + 2 < in.size()) {
const int hi = HexVal(in[i + 1]);
const int lo = HexVal(in[i + 2]);
if (hi >= 0 && lo >= 0) {
out.push_back(static_cast<char>(hi * 16 + lo));
i += 2;
} else {
// Malformed escape: pass the '%' through rather than
// dropping input. Lets a stray '%' in a search box
// survive a round trip.
out.push_back(c);
}
} else {
out.push_back(c);
}
}
return out;
}
std::vector<std::pair<std::string, std::string>> ParseQuery(std::string_view query) {
std::vector<std::pair<std::string, std::string>> out;
if (!query.empty() && query.front() == '?') query.remove_prefix(1);
while (!query.empty()) {
const std::size_t amp = query.find('&');
std::string_view pair = query.substr(0, amp);
query = (amp == std::string_view::npos) ? std::string_view{}
: query.substr(amp + 1);
if (pair.empty()) continue; // tolerate "a=1&&b=2" and a trailing '&'
const std::size_t eq = pair.find('=');
if (eq == std::string_view::npos) {
// Valueless key ("?debug") — present, empty value.
out.emplace_back(PercentDecode(pair), std::string{});
} else {
out.emplace_back(PercentDecode(pair.substr(0, eq)),
PercentDecode(pair.substr(eq + 1)));
}
}
return out;
}
std::optional<std::string> QueryGet(std::string_view query, std::string_view key) {
for (auto& [k, v] : ParseQuery(query)) {
if (k == key) return v;
}
return std::nullopt;
}
}

View file

@ -715,6 +715,17 @@ void Window::StartSync() {
#endif #endif
} }
void Window::StayAlive() {
// Native has no equivalent of the browser's "return from main but keep
// the instance alive" trick — the process simply ends. The event loop
// IS what keeps a native window alive, so the only correct native
// behaviour is StartSync's loop. Delegating keeps a single source that
// compiles identically on both targets: an app can call StayAlive
// unconditionally and get the cheap path in the browser without a
// #ifdef at the call site.
StartSync();
}
void Window::StartUpdate() { void Window::StartUpdate() {
lastFrameBegin = std::chrono::high_resolution_clock::now(); lastFrameBegin = std::chrono::high_resolution_clock::now();
updating = true; updating = true;
@ -1571,6 +1582,23 @@ void Window::StartSync() {
std::_Exit(0); std::_Exit(0);
} }
void Window::StayAlive() {
// As StartSync, minus the rAF loop.
//
// The _Exit(0) is the part that keeps the instance alive: it skips
// __wasm_call_dtors so every statically allocated object (event
// listeners, HtmlElementPtr handles, the Window itself) survives, and
// runtime.js catches the resulting __wasi_proc_exit via a sentinel.
// The frame loop is a separate concern, and a page that only renders
// DOM has no use for it — a rAF tick that runs forever to do nothing
// costs battery on every open tab.
//
// Use this for event-driven DOM pages; use StartSync when something
// actually animates or draws. As with StartSync, no caller code after
// this point ever runs.
std::_Exit(0);
}
void Window::StartUpdate() { void Window::StartUpdate() {
lastFrameBegin = std::chrono::high_resolution_clock::now(); lastFrameBegin = std::chrono::high_resolution_clock::now();
updating = true; updating = true;

View file

@ -61,7 +61,12 @@ export namespace Crafter::Dom {
// DOM ops ───────────────────────────────────────────────────── // DOM ops ─────────────────────────────────────────────────────
void SetInnerHTML(const std::string_view html); void SetInnerHTML(const std::string_view html);
void SetStyle(const std::string_view style); void SetStyle(const std::string_view style);
// WARNING: SetProperty sets a *CSS* property (el.style.setProperty).
// It is NOT setAttribute — the name is historical. For href / src /
// disabled / aria-* / data-*, use SetAttribute below.
void SetProperty(const std::string_view property, const std::string_view value); void SetProperty(const std::string_view property, const std::string_view value);
void AddClass(const std::string_view className); void AddClass(const std::string_view className);
void RemoveClass(const std::string_view className); void RemoveClass(const std::string_view className);
void ToggleClass(const std::string_view className); void ToggleClass(const std::string_view className);
@ -69,28 +74,80 @@ export namespace Crafter::Dom {
std::string GetValue(); std::string GetValue();
void SetValue(const std::string_view value); void SetValue(const std::string_view value);
// Attributes ──────────────────────────────────────────────────
//
// Real setAttribute/getAttribute access. Before these existed the
// only way to change an href or toggle `disabled` was to re-render
// the parent's innerHTML, which destroys every descendant and every
// listener attached to them just to change one string.
void SetAttribute(const std::string_view name, const std::string_view value);
void RemoveAttribute(const std::string_view name);
// nullopt = attribute absent. Distinct from an empty string, which
// means present-with-no-value (`alt=""`, `disabled=""`) — for
// boolean attributes presence alone is the signal.
std::optional<std::string> GetAttribute(const std::string_view name);
bool HasAttribute(const std::string_view name);
// Checkbox / radio state. GetValue cannot express this: `el.value`
// on a checkbox is "on" whether or not it is ticked.
bool GetChecked();
void SetChecked(bool checked);
// Move keyboard focus to / away from this element. AddFocusListener
// could already observe focus, but nothing could set it, which makes
// an accessible "jump to first invalid field" impossible.
void Focus();
void Blur();
// Listener API — each Add* returns an opaque id that can be // Listener API — each Add* returns an opaque id that can be
// passed to the matching Remove*. The destructor automatically // passed to the matching Remove*. The destructor automatically
// removes every handler still registered, so manual removal is // removes every handler still registered, so manual removal is
// optional. Returns 0 only if registration failed at the JS // optional. Returns 0 only if registration failed at the JS
// boundary (the element was already collected). The 23 event // boundary (the element was already collected). The 23 event
// types are 1:1 with CppDOM's surface. // types are 1:1 with CppDOM's surface.
std::int32_t AddClickListener(std::function<void(Crafter::Dom::MouseEvent)> callback); //
// `preventDefault` (mouse / key / wheel kinds only — the others are
// not cancelable events, so the flag would be a silent no-op and is
// deliberately absent):
// Cancels the browser's default action. The JS bridge applies it
// BEFORE dispatching into wasm, so a trap in the callback cannot let
// the default action through anyway.
//
// This is what makes client-side routing over real, crawlable
// `<a href="/foo">` links possible: take the click, cancel the
// navigation, then Router::PushState. Without it the handler runs
// *and* the browser performs a full page load. It is also mandatory
// on dragover/drop — a drop target does not function at all unless
// the default is cancelled.
//
// Wheel additionally registers with `{ passive: false }` when set,
// or the browser drops the cancellation with a console warning.
//
// AddSubmitListener has no flag: its handler always preventDefaults,
// because a native form POST would navigate away from the wasm app.
std::int32_t AddClickListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveClickListener(std::int32_t id); void RemoveClickListener(std::int32_t id);
std::int32_t AddMouseOverListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddMouseOverListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveMouseOverListener(std::int32_t id); void RemoveMouseOverListener(std::int32_t id);
std::int32_t AddMouseOutListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddMouseOutListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveMouseOutListener(std::int32_t id); void RemoveMouseOutListener(std::int32_t id);
std::int32_t AddMouseMoveListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddMouseMoveListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveMouseMoveListener(std::int32_t id); void RemoveMouseMoveListener(std::int32_t id);
std::int32_t AddMouseDownListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddMouseDownListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveMouseDownListener(std::int32_t id); void RemoveMouseDownListener(std::int32_t id);
std::int32_t AddMouseUpListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddMouseUpListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveMouseUpListener(std::int32_t id); void RemoveMouseUpListener(std::int32_t id);
std::int32_t AddFocusListener(std::function<void(Crafter::Dom::FocusEvent)> callback); std::int32_t AddFocusListener(std::function<void(Crafter::Dom::FocusEvent)> callback);
@ -99,13 +156,16 @@ export namespace Crafter::Dom {
std::int32_t AddBlurListener(std::function<void(Crafter::Dom::FocusEvent)> callback); std::int32_t AddBlurListener(std::function<void(Crafter::Dom::FocusEvent)> callback);
void RemoveBlurListener(std::int32_t id); void RemoveBlurListener(std::int32_t id);
std::int32_t AddKeyDownListener(std::function<void(Crafter::Dom::KeyboardEvent)> callback); std::int32_t AddKeyDownListener(std::function<void(Crafter::Dom::KeyboardEvent)> callback,
bool preventDefault = false);
void RemoveKeyDownListener(std::int32_t id); void RemoveKeyDownListener(std::int32_t id);
std::int32_t AddKeyUpListener(std::function<void(Crafter::Dom::KeyboardEvent)> callback); std::int32_t AddKeyUpListener(std::function<void(Crafter::Dom::KeyboardEvent)> callback,
bool preventDefault = false);
void RemoveKeyUpListener(std::int32_t id); void RemoveKeyUpListener(std::int32_t id);
std::int32_t AddKeyPressListener(std::function<void(Crafter::Dom::KeyboardEvent)> callback); std::int32_t AddKeyPressListener(std::function<void(Crafter::Dom::KeyboardEvent)> callback,
bool preventDefault = false);
void RemoveKeyPressListener(std::int32_t id); void RemoveKeyPressListener(std::int32_t id);
std::int32_t AddChangeListener(std::function<void(Crafter::Dom::ChangeEvent)> callback); std::int32_t AddChangeListener(std::function<void(Crafter::Dom::ChangeEvent)> callback);
@ -123,28 +183,36 @@ export namespace Crafter::Dom {
std::int32_t AddScrollListener(std::function<void(Crafter::Dom::ScrollEvent)> callback); std::int32_t AddScrollListener(std::function<void(Crafter::Dom::ScrollEvent)> callback);
void RemoveScrollListener(std::int32_t id); void RemoveScrollListener(std::int32_t id);
std::int32_t AddContextMenuListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddContextMenuListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveContextMenuListener(std::int32_t id); void RemoveContextMenuListener(std::int32_t id);
std::int32_t AddDragStartListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddDragStartListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveDragStartListener(std::int32_t id); void RemoveDragStartListener(std::int32_t id);
std::int32_t AddDragEndListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddDragEndListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveDragEndListener(std::int32_t id); void RemoveDragEndListener(std::int32_t id);
std::int32_t AddDropListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddDropListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveDropListener(std::int32_t id); void RemoveDropListener(std::int32_t id);
std::int32_t AddDragOverListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddDragOverListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveDragOverListener(std::int32_t id); void RemoveDragOverListener(std::int32_t id);
std::int32_t AddDragEnterListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddDragEnterListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveDragEnterListener(std::int32_t id); void RemoveDragEnterListener(std::int32_t id);
std::int32_t AddDragLeaveListener(std::function<void(Crafter::Dom::MouseEvent)> callback); std::int32_t AddDragLeaveListener(std::function<void(Crafter::Dom::MouseEvent)> callback,
bool preventDefault = false);
void RemoveDragLeaveListener(std::int32_t id); void RemoveDragLeaveListener(std::int32_t id);
std::int32_t AddWheelListener(std::function<void(Crafter::Dom::WheelEvent)> callback); std::int32_t AddWheelListener(std::function<void(Crafter::Dom::WheelEvent)> callback,
bool preventDefault = false);
void RemoveWheelListener(std::int32_t id); void RemoveWheelListener(std::int32_t id);
protected: protected:

View file

@ -17,18 +17,62 @@ export namespace Crafter::Router {
// Push a new history entry. `data` is a JSON string serialized by // Push a new history entry. `data` is a JSON string serialized by
// the caller — the browser stores it on the entry but the popstate // the caller — the browser stores it on the entry but the popstate
// listener in V1 receives no payload (matches CppDOM's surface). // listener in V1 receives no payload (matches CppDOM's surface).
// `url` is browser-relative, e.g. "/blog/post-1". // `url` is browser-relative, e.g. "/blog/post-1". Cannot leave the
// origin; use Navigate for that.
void PushState(std::string_view data, std::string_view title, std::string_view url); void PushState(std::string_view data, std::string_view title, std::string_view url);
// As PushState, but rewrites the current entry instead of adding one.
// For URL changes that shouldn't become their own Back destination:
// filter/sort state, canonicalising a sloppy incoming URL.
void ReplaceState(std::string_view data, std::string_view title, std::string_view url);
// Subscribe to the browser's `popstate` event (back/forward button, // Subscribe to the browser's `popstate` event (back/forward button,
// programmatic history.go). Returns an opaque id usable with // programmatic history.go). Returns an opaque id usable with
// `RemovePopStateListener`. Multiple subscribers OK. // `RemovePopStateListener`. Multiple subscribers OK.
//
// The callback still receives no payload — re-read the location with
// the accessors below rather than relying on the pushState `data`.
std::int32_t AddPopStateListener(std::function<void()> callback); std::int32_t AddPopStateListener(std::function<void()> callback);
void RemovePopStateListener(std::int32_t id); void RemovePopStateListener(std::int32_t id);
// Current `window.location.pathname` as a freshly-allocated string. // Location accessors. Each allocates a fresh string per call — cache
// Allocates per call — cache the result in the caller if used in // the result if used in a hot path.
// hot paths. //
std::string GetPath(); // GetSearch / GetHash include their leading '?' / '#', so an absent
// query and a bare "?" stay distinguishable and the result can be
// concatenated straight back onto a path.
std::string GetPath(); // window.location.pathname
std::string GetSearch(); // window.location.search, e.g. "?page=2&sort=new"
std::string GetHash(); // window.location.hash, e.g. "#reviews"
std::string GetHref(); // window.location.href, absolute
// Full-page navigation, cross-origin allowed. PushState deliberately
// cannot leave the origin, so this is the only way to hand the user off
// to an external URL (a hosted payment page, an OAuth consent screen).
// `replace == true` drops the current page from history, so Back won't
// return to a stale page.
void Navigate(std::string_view url, bool replace = false);
// ─── Query-string helpers ─────────────────────────────────────────
//
// Pure string functions — no JS round-trip, safe to call on any
// string, not just the live location. Provided here so callers don't
// each hand-roll percent decoding; `+` vs `%20` is the usual bug.
// Split a query string into decoded key/value pairs, in source order.
// Accepts an optional leading '?'. Duplicate keys are preserved as
// separate entries (?tag=a&tag=b yields two). A valueless key
// ("?debug") yields an empty value. Empty segments are skipped, so
// "a=1&&b=2" and a trailing '&' are tolerated.
std::vector<std::pair<std::string, std::string>> ParseQuery(std::string_view query);
// First value for `key`, or nullopt if absent. Distinguishes "missing"
// from "present but empty" — which ?debug and ?q= need.
std::optional<std::string> QueryGet(std::string_view query, std::string_view key);
// Percent-decode a single component, treating '+' as a space per
// application/x-www-form-urlencoded. A malformed escape is passed
// through literally rather than dropped.
std::string PercentDecode(std::string_view in);
} }
#endif // CRAFTER_GRAPHICS_WINDOW_DOM #endif // CRAFTER_GRAPHICS_WINDOW_DOM

View file

@ -121,7 +121,22 @@ export namespace Crafter {
~Window(); ~Window();
#endif #endif
// Enter the platform event loop and never return. In DOM mode this
// hands the loop to requestAnimationFrame and _Exit(0)s so the wasm
// instance survives without running static destructors. No caller
// code after this point ever runs, on any target.
void StartSync(); void StartSync();
// As StartSync, but does NOT start the animation-frame loop in DOM
// mode — for event-driven pages that only touch the DOM and never
// draw. A rAF tick running forever to do nothing costs battery in
// every open tab. Still never returns.
//
// On native this is identical to StartSync: the event loop is what
// keeps a window alive there, so there is no cheaper option. Call it
// unconditionally and get the browser saving without an #ifdef.
void StayAlive();
void StartUpdate(); void StartUpdate();
void StopUpdate(); void StopUpdate();
void SetTitle(const std::string_view title); void SetTitle(const std::string_view title);

View file

@ -192,7 +192,31 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// JS glue shipped alongside the .wasm so the loader has the // JS glue shipped alongside the .wasm so the loader has the
// env-import surface the Window/Dom bindings expect. // env-import surface the Window/Dom bindings expect.
cfg.files.emplace_back(fs::path("additional/dom-env.js")); cfg.files.emplace_back(fs::path("additional/dom-env.js"));
// dom-webgpu.js is opt-out via --no-webgpu, for apps that only use
// the Dom/Router partitions and never touch Device/RTPass/UIRenderer.
//
// Three reasons a DOM-only app wants it gone, in increasing order of
// severity:
// 1. It is ~182 KB (47 KB gzip) of dead weight.
// 2. Its init is an async IIFE that runtime.js awaits before
// _start(), and that init does `await navigator.gpu
// .requestAdapter()` — so every page load blocks wasm startup on
// a GPU adapter request, including on machines with no GPU.
// 3. Worst: when navigator.gpu is absent it REPLACES
// document.body.innerHTML with an error message and throws, so
// _start() never runs. For a server-rendered page that means the
// real content is destroyed and replaced with a WebGPU warning on
// any browser without WebGPU support.
//
// Safety: with --gc-sections an app that never calls into the WebGPU
// partitions emits no wgpu* env imports, so omitting the bridge links
// and instantiates cleanly. An app that DOES use WebGPU and passes
// --no-webgpu anyway fails at instantiate with a missing-import error
// naming the specific wgpu* symbol.
if (!opts.Has("--no-webgpu")) {
cfg.files.emplace_back(fs::path("additional/dom-webgpu.js")); cfg.files.emplace_back(fs::path("additional/dom-webgpu.js"));
}
} else { } else {
std::array<fs::path, 14> impls = { std::array<fs::path, 14> impls = {
"implementations/Crafter.Graphics-Clipboard", "implementations/Crafter.Graphics-Clipboard",