pcbjam/site/public/gerber-demo/wasm/wx-dom.js
Viktor Vaczi 57f1f53d26 feat: lazily-loaded Gerber viewer demo in the blog, served from R2
Embed KiCad's gerbview WASM in the graphics-to-WebGL blog post as a
click-to-load demo that opens the tiny_tapeout board entirely in-browser.

- GerberDemo.astro: lazy iframe embed + cross-origin-isolation reload guard
  + WebGL2/SharedArrayBuffer feature-detect with a poster fallback
- public/gerber-demo/{index.html,boot.js}: self-contained gerbview boot
  harness (config seed, MEMFS board preload, argv auto-open)
- board/ tiny_tapeout layers + poster.png committed; wasm/ glue JS committed
  (served same-origin — pthread worker can't be cross-origin), while
  gerbview.wasm + kicad-resources.bin are git-ignored and served from
  Cloudflare R2 (assets.pcbjam.com)
- COOP/COEP require-corp scoped to the post + /gerber-demo routes
  (dev: astro.config.mjs + middleware.ts; prod: vercel.json)
- post converted to MDX (@astrojs/mdx) so it can embed the component
- scripts/: sync-demo-wasm.sh, r2-deploy.sh, r2-cors.json

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:28:06 +02:00

1572 lines
58 KiB
JavaScript

// wx-dom.js — shim additions for the WASM DOM port (non-universal build).
//
// Loaded as a second --pre-js AFTER wx.js, only in DOM-port bundles.
// wx.js keeps owning window management, the element registry, clipboard,
// file dialogs and the Canvas2D drawing used by owner-drawn widgets
// (canvas islands). This file owns native-control DOM elements: creation
// (type-based factory, composites where a wx control maps to more than one
// element), property sync, clone-based intrinsic measurement, and event
// wiring back into WASM via wx_dom_event (src/wasm/domevents.cpp).
//
// All functions are no-ops/guarded in Web Workers (-pthread evaluates
// pre-js there too) — see the wx.js worker guard precedent.
(function () {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
var nextControlId = 1;
var controls = new Map(); // domId -> root HTMLElement
var inputs = new Map(); // domId -> value-bearing element (if != root)
var labels = new Map(); // domId -> label text target (if != root)
// Flag read by the C++ keyboard callback (src/wasm/app.cpp): while a DOM
// editable owns browser focus, wx must not swallow/preventDefault keys.
window.wxDomEditableFocused = 0;
// Mirror of wxDomEventKind in include/wx/wasm/window.h.
var EVT = { CLICK: 1, INPUT: 2, CHANGE: 3, FOCUSIN: 4, FOCUSOUT: 5,
ENTER: 6, SPIN_UP: 7, SPIN_DOWN: 8, MENU: 9, TOOL: 10,
TAB: 11, SCROLL: 12 };
// Last pointer position in viewport (clientX/Y) coordinates — read by
// wxShowContextMenu when DoPopupMenu is invoked "at the mouse" (the KiCad
// canvas right-click case passes wxDefaultCoord). Tracked on every pointer
// event below, including over the GAL canvas (where forwardTarget is null).
var lastPointerClientX = 0;
var lastPointerClientY = 0;
// Marks the bundle as DOM-port for tests/boot code.
window.wxDomPort = true;
window.wxDomControls = controls;
function dispatch(domId, kind) {
try {
Module['ccall']('wx_dom_event', null, ['number', 'number'], [domId, kind]);
} catch (e) {
// Surfaces in test logs; must never throw back into DOM event handlers.
console.error('wx_dom_event(' + domId + ',' + kind + ') failed:', e);
}
}
function isEditable(el) {
return el && (el.tagName === 'TEXTAREA' ||
(el.tagName === 'INPUT' &&
['text', 'password', 'number', 'search'].indexOf(el.type) >= 0));
}
function flexCenter(el) {
el.dataset.wxDisplay = 'flex';
el.style.display = 'flex';
el.style.alignItems = 'center';
}
// Builds the element structure for a logical wx control type. Plain HTML
// tags ("span", "button", "textarea", "input"+typeAttr) pass through for
// the simple controls; composite types build wrappers.
// Returns { root, input, label } (input/label optional).
function buildControl(type, typeAttr) {
var root, input, label;
switch (type) {
case 'checkbox':
case 'radio': {
root = document.createElement('label');
input = document.createElement('input');
input.type = type;
input.style.margin = '0 3px 0 0';
label = document.createElement('span');
label.className = 'wx-label';
root.appendChild(input);
root.appendChild(label);
flexCenter(root);
break;
}
case 'toggle': {
root = document.createElement('button');
root.setAttribute('aria-pressed', 'false');
flexCenter(root);
root.style.justifyContent = 'center';
root.style.padding = '1px 9px';
break;
}
case 'statbox': {
// Visual chrome only: the wx children of a wxStaticBox are SIBLING
// DOM controls, so the fieldset must never intercept their input.
root = document.createElement('fieldset');
root.style.border = '1px solid #b5b2aa';
root.style.borderRadius = '2px';
label = document.createElement('legend');
label.className = 'wx-label';
label.style.padding = '0 3px';
root.appendChild(label);
root.dataset.wxChrome = '1';
break;
}
case 'statline': {
root = document.createElement('div');
root.style.background = '#909090';
root.dataset.wxChrome = '1';
break;
}
case 'gauge': {
root = document.createElement('progress');
root.max = 100;
root.value = 0;
break;
}
case 'slider': {
root = document.createElement('input');
root.type = 'range';
break;
}
case 'scrollbar': {
// Custom track+thumb (a native <input type=range> can't express a
// proportional thumb). Drag handlers are wired in wxDomCreateControl
// once the domId is known; metrics arrive via wxDomSetScrollbar.
// typeAttr "v" = vertical, "h" = horizontal.
root = document.createElement('div');
root.dataset.wxScrollbar = '1';
var sbVertical = (typeAttr === 'v');
root.style.background = '#e8e8e8';
root.style.userSelect = 'none';
var sbTrack = document.createElement('div');
sbTrack.className = 'wx-sb-track';
sbTrack.style.cssText = 'position:absolute;left:0;top:0;right:0;bottom:0;';
var sbThumb = document.createElement('div');
sbThumb.className = 'wx-sb-thumb';
sbThumb.style.cssText =
'position:absolute;background:#a0a0a0;border:1px solid #808080;' +
'border-radius:2px;box-sizing:border-box;touch-action:none;' +
'display:none;' +
(sbVertical ? 'left:0;right:0;top:0;height:0;'
: 'top:0;bottom:0;left:0;width:0;');
sbTrack.appendChild(sbThumb);
root.appendChild(sbTrack);
root._wxSb = { pos: 0, thumb: 0, range: 0, page: 0,
vertical: sbVertical, dragging: false, phase: 1,
dragStart: 0, dragStartOffset: 0 };
break;
}
case 'choice': {
root = document.createElement('select');
break;
}
case 'listbox': {
root = document.createElement('select');
root.multiple = true;
break;
}
case 'spinbutton': {
// vertical up/down button pair; clicks dispatch SPIN_UP/SPIN_DOWN
root = document.createElement('div');
root.dataset.wxSpin = '1';
var mk = function (txt, cls) {
var b = document.createElement('button');
b.textContent = txt;
b.className = cls;
b.style.cssText = 'flex:1;padding:0;margin:0;font-size:7px;' +
'line-height:1;min-height:0;overflow:hidden;';
root.appendChild(b);
return b;
};
root.style.display = 'flex';
root.dataset.wxDisplay = 'flex';
root.style.flexDirection = 'column';
mk('▲', 'wx-spin-up');
mk('▼', 'wx-spin-down');
break;
}
case 'radiobox': {
// owns its item rows (unlike statbox chrome): fieldset + legend +
// one <label><input type=radio><span></span></label> per item,
// filled by wxDomSetItems.
root = document.createElement('fieldset');
root.style.border = '1px solid #b5b2aa';
root.style.borderRadius = '2px';
label = document.createElement('legend');
label.className = 'wx-label';
label.style.padding = '0 3px';
root.appendChild(label);
root.dataset.wxRadioBox = '1';
break;
}
case 'image': {
root = document.createElement('img');
root.dataset.wxChrome = '1'; // non-interactive like statbmp
break;
}
case 'combobox': {
// Editable combo: text input + datalist autocomplete (HTML has no
// native editable select). wxDomSetItems fills the datalist.
root = document.createElement('input');
root.type = 'text';
var dl = document.createElement('datalist');
dl.id = 'wx-datalist-' + nextControlId; // == the domId assigned below
root.setAttribute('list', dl.id);
document.body.appendChild(dl);
root.dataset.wxDatalist = dl.id;
break;
}
case 'checklistbox': {
// Scrollable list of checkbox rows; row checkbox toggles dispatch
// CHANGE with the row index retrievable via wxDomGetIntValue.
root = document.createElement('div');
root.dataset.wxCheckList = '1';
root.style.overflowY = 'auto';
root.style.background = '#ffffff';
root.style.border = '1px solid #b5b2aa';
break;
}
case 'menubar': {
// Horizontal strip of menu-title buttons; menus open as popup divs
// (built by wxDomMenuSetStructure).
root = document.createElement('div');
root.dataset.wxMenuBar = '1';
root.style.background = '#d4d0c8';
flexCenter(root);
break;
}
case 'toolbar': {
// Horizontal strip of tool buttons (built by wxDomToolbarSetTools).
root = document.createElement('div');
root.dataset.wxToolBar = '1';
root.style.background = '#d4d0c8';
flexCenter(root);
break;
}
case 'notebook': {
// The root box covers the whole page area, so it is CHROME — it
// must never swallow mouse events meant for page content (canvas
// islands and sibling DOM controls). Only the tab strip inside is
// interactive (built by wxDomNotebookSetTabs).
root = document.createElement('div');
root.dataset.wxNotebook = '1';
root.dataset.wxChrome = '1';
var strip = document.createElement('div');
strip.className = 'wx-tab-strip';
strip.setAttribute('role', 'tablist');
strip.style.cssText =
'position:absolute;left:0;top:0;right:0;display:flex;' +
'align-items:flex-end;background:#d4d0c8;' +
'border-bottom:1px solid #808080;pointer-events:auto;' +
'overflow:hidden;';
root.appendChild(strip);
break;
}
default: {
root = document.createElement(type);
if (typeAttr) root.setAttribute('type', typeAttr);
if (type === 'span' || type === 'button' || type === 'label') {
flexCenter(root);
}
if (type === 'button') {
root.style.padding = '1px 9px';
root.style.justifyContent = 'center';
}
break;
}
}
// Passive controls have no interactive DOM behavior of their own —
// left clicks on them belong to wx (KiCad binds LEFT_DOWN on labels).
if (root && (type === 'span' || type === 'gauge')) {
root.dataset.wxPassive = '1';
}
return { root: root, input: input, label: label };
}
window.wxDomCreateControl = function (tlwCssId, type, typeAttr) {
var container = window.__wxGetWindowElement(tlwCssId);
if (!container) {
console.error('wxDomCreateControl: no window element for css id ' + tlwCssId);
return 0;
}
var built = buildControl(type, typeAttr);
var el = built.root;
var domId = nextControlId++;
el.dataset.wxDomId = String(domId);
el.classList.add('wx-dom-control');
el.style.position = 'absolute';
el.style.left = '0px';
el.style.top = '0px';
el.style.boxSizing = 'border-box';
el.style.margin = '0';
if (!el.style.padding) el.style.padding = '0';
el.style.overflow = type === 'statbox' ? 'visible' : 'hidden';
if (el.dataset.wxDisplay === undefined) el.dataset.wxDisplay = '';
// wx labels never soft-wrap (the wx sizer sizes them exactly), but
// multiline wxStaticText labels DO contain hard \n breaks — 'pre'
// preserves those while preventing wrapping. <textarea> keeps native
// wrapping.
if (type !== 'textarea') {
el.style.whiteSpace = 'pre';
}
// The TLW container has pointer-events:none so input funnels to the
// canvas; real controls take their own events — except pure chrome
// (statbox/statline), which must not block canvas hit-testing or
// sibling controls.
el.style.pointerEvents = el.dataset.wxChrome ? 'none' : 'auto';
var valueEl = built.input || el;
if (built.input) inputs.set(domId, built.input);
if (built.label) labels.set(domId, built.label);
if (el.dataset.wxSpin) {
el.querySelector('.wx-spin-up').addEventListener('click', function (ev) {
dispatch(domId, EVT.SPIN_UP);
ev.stopPropagation();
});
el.querySelector('.wx-spin-down').addEventListener('click', function (ev) {
dispatch(domId, EVT.SPIN_DOWN);
ev.stopPropagation();
});
} else if (el.dataset.wxScrollbar) {
wireScrollbar(domId, el);
} else if (!el.dataset.wxChrome) {
el.addEventListener('click', function (ev) {
dispatch(domId, EVT.CLICK);
ev.stopPropagation();
});
el.addEventListener('focusin', function () {
if (isEditable(valueEl)) {
window.wxDomEditableFocused = 1;
}
dispatch(domId, EVT.FOCUSIN);
});
el.addEventListener('focusout', function () {
if (isEditable(valueEl)) {
window.wxDomEditableFocused = 0;
}
dispatch(domId, EVT.FOCUSOUT);
});
valueEl.addEventListener('input', function () {
dispatch(domId, EVT.INPUT);
});
valueEl.addEventListener('change', function () {
dispatch(domId, EVT.CHANGE);
});
if (isEditable(valueEl)) {
valueEl.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' && valueEl.tagName === 'INPUT') {
dispatch(domId, EVT.ENTER);
}
// Typing belongs to the input; don't let the window-level
// Emscripten keyboard handler see it (belt — the C++ callback
// also checks wxDomEditableFocused as suspenders).
ev.stopPropagation();
});
valueEl.addEventListener('keyup', function (ev) {
ev.stopPropagation();
});
}
}
controls.set(domId, el);
container.appendChild(el);
return domId;
};
window.wxDomDestroyControl = function (domId) {
var el = controls.get(domId);
if (el) {
if (el.dataset.wxDatalist) {
var dl = document.getElementById(el.dataset.wxDatalist);
if (dl) dl.remove();
}
el.remove();
controls.delete(domId);
inputs.delete(domId);
labels.delete(domId);
// drop this control's mirrored e2e-registry entries (tabs, spin
// arrows, text fields)
var reg = window.wxElementRegistry;
if (reg && reg.renderedElements) {
var stale = [];
reg.renderedElements.forEach(function (info, key) {
if (String(key).indexOf(domId + ':') === 0) stale.push(key);
});
stale.forEach(function (key) { reg.unregisterRendered(key); });
}
}
};
window.wxDomSetRect = function (domId, x, y, w, h) {
var el = controls.get(domId);
if (!el) return;
el.style.left = x + 'px';
el.style.top = y + 'px';
el.style.width = w + 'px';
el.style.height = h + 'px';
// Notebook tabs register their viewport rects in the e2e registry;
// unlike canvas tabs they don't repaint on move, so re-sync here.
if (el.dataset.wxNotebook && el._wxTabs) {
scheduleTabRegistry(domId, el);
}
// Same for spin arrows and text fields (the canvas port published
// them from paint hooks; DOM controls don't repaint on move).
if (el.dataset.wxSpin || el.tagName === 'INPUT' ||
el.tagName === 'TEXTAREA') {
scheduleControlRegistry(domId, el);
}
// Scrollbars re-flow the thumb to the new track length, then re-publish
// their drag targets to the e2e registry.
if (el.dataset.wxScrollbar) {
layoutScrollbar(el);
scheduleScrollbarRegistry(domId, el);
}
};
// Label text: routed to the inner label element for composites
// (checkbox/radio span, statbox legend), the element itself otherwise.
window.wxDomSetText = function (domId, text) {
var target = labels.get(domId) || controls.get(domId);
if (target) target.textContent = text;
};
window.wxDomSetValue = function (domId, value) {
var el = inputs.get(domId) || controls.get(domId);
if (el) el.value = value;
};
window.wxDomGetValue = function (domId) {
var el = inputs.get(domId) || controls.get(domId);
return el ? String(el.value) : '';
};
// Boolean state: checkbox/radio checked, toggle button pressed.
window.wxDomSetBoolValue = function (domId, on) {
var el = inputs.get(domId) || controls.get(domId);
if (!el) return;
if (el.tagName === 'INPUT') {
el.checked = !!on;
} else {
el.setAttribute('aria-pressed', on ? 'true' : 'false');
el.style.background = on ? '#b0c4de' : '';
}
};
window.wxDomGetBoolValue = function (domId) {
var el = inputs.get(domId) || controls.get(domId);
if (!el) return 0;
if (el.tagName === 'INPUT') return el.checked ? 1 : 0;
return el.getAttribute('aria-pressed') === 'true' ? 1 : 0;
};
// Numeric state: gauge/slider value, select/radiobox/combobox selection.
window.wxDomSetIntValue = function (domId, value) {
var el = inputs.get(domId) || controls.get(domId);
if (!el) return;
if (el.tagName === 'SELECT') {
el.selectedIndex = value;
} else if (el.dataset && el.dataset.wxRadioBox) {
var radios = el.querySelectorAll('input[type=radio]');
if (radios[value]) radios[value].checked = true;
} else if (el.dataset && el.dataset.wxDatalist) {
// combobox selection = the nth datalist option's text
var dl = document.getElementById(el.dataset.wxDatalist);
var opt = dl && dl.options[value];
if (opt) el.value = opt.value;
} else if (el.dataset && el.dataset.wxScrollbar) {
// scrollbar thumb position (wxScrollBar::SetThumbPosition). Ignore
// programmatic moves mid-drag — the user owns the thumb then.
if (el._wxSb && !el._wxSb.dragging) {
el._wxSb.pos = value;
layoutScrollbar(el);
}
} else {
el.value = value;
}
};
window.wxDomGetIntValue = function (domId) {
var el = inputs.get(domId) || controls.get(domId);
if (!el) return 0;
if (el.dataset && el.dataset.wxScrollbar) {
return el._wxSb ? el._wxSb.pos : 0;
}
if (el.tagName === 'SELECT') return el.selectedIndex;
if (el.dataset && el.dataset.wxRadioBox) {
var radios = el.querySelectorAll('input[type=radio]');
for (var i = 0; i < radios.length; i++) {
if (radios[i].checked) return i;
}
return -1;
}
if (el.dataset && el.dataset.wxCheckList) {
// index of the row whose checkbox last toggled (for wxEVT_CHECKLISTBOX)
var t = parseInt(el.dataset.wxLastToggled, 10);
return isNaN(t) ? -1 : t;
}
if (el.dataset && el.dataset.wxDatalist) {
// combobox selection = index of the option matching the current text
var dl = document.getElementById(el.dataset.wxDatalist);
if (dl) {
for (var j = 0; j < dl.options.length; j++) {
if (dl.options[j].value === el.value) return j;
}
}
return -1;
}
var v = parseInt(el.value, 10);
return isNaN(v) ? 0 : v;
};
window.wxDomSetRange = function (domId, minVal, maxVal) {
var el = inputs.get(domId) || controls.get(domId);
if (!el) return;
if (el.tagName === 'PROGRESS') {
el.max = maxVal;
} else {
el.min = minVal;
el.max = maxVal;
}
};
// ========== Scrollbars (track + draggable thumb) ==========
//
// One shared widget backs both the standalone wxScrollBar and a wxWindow's
// built-in gutters. C++ feeds (pos, thumb, range, page) + orientation via
// wxDomSetScrollbar; drags report position + phase back through
// wxDOM_EVENT_SCROLL (read with wxDomGetIntValue / wxDomGetScrollPhase).
//
// wx scrollbar semantics: `thumb` is the visible portion (the thumb's
// proportion of the track is thumb/range), `pos` ranges 0..range-thumb,
// and `page` is the track-click step. Auto-hide when thumb >= range.
// Position the thumb for the current metrics; auto-hide when nothing scrolls.
function layoutScrollbar(el) {
var sb = el._wxSb;
if (!sb) return;
var track = el.querySelector('.wx-sb-track');
var thumb = el.querySelector('.wx-sb-thumb');
if (!track || !thumb) return;
var trackPx = sb.vertical ? track.clientHeight : track.clientWidth;
if (sb.range <= 0 || sb.thumb <= 0 || sb.thumb >= sb.range || trackPx <= 0) {
thumb.style.display = 'none';
return;
}
thumb.style.display = 'block';
var thumbPx = Math.min(trackPx,
Math.max(12, Math.round(trackPx * sb.thumb / sb.range)));
var scrollable = sb.range - sb.thumb;
var pos = Math.max(0, Math.min(sb.pos, scrollable));
var offsetPx = scrollable > 0
? Math.round((trackPx - thumbPx) * pos / scrollable) : 0;
if (sb.vertical) {
thumb.style.top = offsetPx + 'px';
thumb.style.height = thumbPx + 'px';
} else {
thumb.style.left = offsetPx + 'px';
thumb.style.width = thumbPx + 'px';
}
}
// Map a thumb offset (px along the track) back to a scrollbar position.
function scrollbarPosFromOffset(el, offsetPx) {
var sb = el._wxSb;
var track = el.querySelector('.wx-sb-track');
var thumb = el.querySelector('.wx-sb-thumb');
var trackPx = sb.vertical ? track.clientHeight : track.clientWidth;
var thumbPx = sb.vertical ? thumb.offsetHeight : thumb.offsetWidth;
var span = trackPx - thumbPx;
var scrollable = sb.range - sb.thumb;
if (span <= 0 || scrollable <= 0) return 0;
return Math.max(0, Math.min(scrollable, Math.round(offsetPx / span * scrollable)));
}
function wireScrollbar(domId, el) {
var sb = el._wxSb;
var track = el.querySelector('.wx-sb-track');
var thumb = el.querySelector('.wx-sb-thumb');
thumb.addEventListener('pointerdown', function (ev) {
ev.preventDefault();
ev.stopPropagation();
sb.dragging = true;
try { thumb.setPointerCapture(ev.pointerId); } catch (e) {}
sb.dragStart = sb.vertical ? ev.clientY : ev.clientX;
sb.dragStartOffset = sb.vertical ? thumb.offsetTop : thumb.offsetLeft;
});
thumb.addEventListener('pointermove', function (ev) {
if (!sb.dragging) return;
var delta = (sb.vertical ? ev.clientY : ev.clientX) - sb.dragStart;
var newPos = scrollbarPosFromOffset(el, sb.dragStartOffset + delta);
if (newPos !== sb.pos) {
sb.pos = newPos;
layoutScrollbar(el);
sb.phase = 0; // thumbtrack
dispatch(domId, EVT.SCROLL);
}
});
var endDrag = function (ev) {
if (!sb.dragging) return;
sb.dragging = false;
try { thumb.releasePointerCapture(ev.pointerId); } catch (e) {}
sb.phase = 1; // thumbrelease
dispatch(domId, EVT.SCROLL);
};
thumb.addEventListener('pointerup', endDrag);
thumb.addEventListener('pointercancel', endDrag);
// Track click outside the thumb = page step toward the click.
track.addEventListener('pointerdown', function (ev) {
if (ev.target === thumb || sb.range <= sb.thumb) return;
var rect = track.getBoundingClientRect();
var clickPos = sb.vertical ? (ev.clientY - rect.top) : (ev.clientX - rect.left);
var thumbStart = sb.vertical ? thumb.offsetTop : thumb.offsetLeft;
var dir = clickPos < thumbStart ? -1 : 1;
var scrollable = sb.range - sb.thumb;
var step = sb.page > 0 ? sb.page : sb.thumb;
var newPos = Math.max(0, Math.min(scrollable, sb.pos + dir * step));
if (newPos !== sb.pos) {
sb.pos = newPos;
layoutScrollbar(el);
sb.phase = 2; // page step
dispatch(domId, EVT.SCROLL);
}
});
}
// Push metrics from C++ (wxScrollBar / wxWindow built-in scrollbars).
window.wxDomSetScrollbar = function (domId, pos, thumb, range, page) {
var el = controls.get(domId);
if (!el || !el._wxSb) return;
var sb = el._wxSb;
sb.thumb = thumb;
sb.range = range;
sb.page = page;
if (!sb.dragging) sb.pos = pos; // the user owns the thumb mid-drag
layoutScrollbar(el);
scheduleScrollbarRegistry(domId, el);
};
// Phase of the last scroll interaction: 0 track, 1 release, 2 page.
window.wxDomGetScrollPhase = function (domId) {
var el = controls.get(domId);
return el && el._wxSb ? (el._wxSb.phase | 0) : 0;
};
// Publish the thumb ('slider') and track ('slidertrack') to the e2e
// registry so Playwright's dragSliderTo() can drag a scrollbar unchanged.
// dragSliderTo reads screenX/screenY off the track, so map them explicitly
// (rectInfo only carries x/y).
function scheduleScrollbarRegistry(domId, el) {
requestAnimationFrame(function () {
var reg = window.wxElementRegistry;
if (!reg || !el.isConnected || !el._wxSb) return;
var stale = [];
reg.renderedElements.forEach(function (info, key) {
var k = String(key);
if (k.indexOf(domId + ':slider:') === 0 ||
k.indexOf(domId + ':slidertrack:') === 0) stale.push(key);
});
stale.forEach(function (key) { reg.unregisterRendered(key); });
var track = el.querySelector('.wx-sb-track');
var thumb = el.querySelector('.wx-sb-thumb');
if (!track || !thumb || thumb.style.display === 'none') return;
var sub = el._wxSb.vertical ? 'vertical' : 'horizontal';
var tr = track.getBoundingClientRect();
var th = thumb.getBoundingClientRect();
registryRegister(domId + ':slidertrack:0', {
elementType: 'slidertrack', subType: sub, label: '', tooltip: '',
enabled: true, parentId: String(domId), index: 0,
screenX: tr.x, screenY: tr.y, width: tr.width, height: tr.height,
centerX: tr.x + tr.width / 2, centerY: tr.y + tr.height / 2
});
registryRegister(domId + ':slider:0', {
elementType: 'slider', subType: sub, label: '', tooltip: '',
enabled: true, parentId: String(domId), index: 0,
screenX: th.x, screenY: th.y, width: th.width, height: th.height,
centerX: th.x + th.width / 2, centerY: th.y + th.height / 2
});
});
}
// HTML radio exclusivity groups via the name attribute (wx groups are
// defined by wxRB_GROUP chains; C++ passes a stable per-group name).
window.wxDomSetGroupName = function (domId, name) {
var el = inputs.get(domId);
if (el) el.name = name;
};
// Item lists for select-likes and radiobox; items arrive \x1f-joined
// (the unit separator can't occur in wx labels).
window.wxDomSetItems = function (domId, joined) {
var el = controls.get(domId);
if (!el) return;
var items = joined === '' ? [] : joined.split('\x1f');
if (el.dataset.wxDatalist) {
var dl = document.getElementById(el.dataset.wxDatalist);
if (dl) {
dl.textContent = '';
items.forEach(function (it) {
var o = document.createElement('option');
o.value = it;
dl.appendChild(o);
});
}
} else if (el.dataset.wxCheckList) {
el.textContent = '';
items.forEach(function (it, idx) {
var row = document.createElement('label');
row.style.cssText =
'display:flex;align-items:center;padding:0 2px;white-space:pre;';
var inp = document.createElement('input');
inp.type = 'checkbox';
inp.style.margin = '0 3px 0 0';
inp.addEventListener('change', function () {
el.dataset.wxLastToggled = String(idx);
dispatch(domId, EVT.CHANGE);
});
var sp = document.createElement('span');
sp.textContent = it;
row.appendChild(inp);
row.appendChild(sp);
el.appendChild(row);
});
} else if (el.tagName === 'SELECT') {
el.textContent = '';
items.forEach(function (it) {
var o = document.createElement('option');
o.textContent = it;
el.appendChild(o);
});
} else if (el.dataset.wxRadioBox) {
Array.prototype.forEach.call(el.querySelectorAll('label'), function (r) {
r.remove();
});
items.forEach(function (it) {
var row = document.createElement('label');
row.style.cssText =
'display:flex;align-items:center;margin:1px 4px;white-space:pre;';
var inp = document.createElement('input');
inp.type = 'radio';
inp.name = 'wxradiobox-' + domId;
inp.style.margin = '0 3px 0 0';
inp.addEventListener('change', function () {
dispatch(domId, EVT.CHANGE);
});
var sp = document.createElement('span');
sp.textContent = it;
row.appendChild(inp);
row.appendChild(sp);
el.appendChild(row);
});
}
};
// Multi-selection (listbox) / per-item checked state (checklistbox):
// per-index boolean; "selected" indices returned comma-joined.
window.wxDomSetItemSelected = function (domId, index, on) {
var el = controls.get(domId);
if (!el) return;
if (el.dataset.wxCheckList) {
var boxes = el.querySelectorAll('input[type=checkbox]');
if (boxes[index]) boxes[index].checked = !!on;
} else if (el.tagName === 'SELECT' && el.options[index]) {
el.options[index].selected = !!on;
}
};
window.wxDomGetSelectedIndices = function (domId) {
var el = controls.get(domId);
if (!el) return '';
var out = [];
var i;
if (el.dataset.wxCheckList) {
var boxes = el.querySelectorAll('input[type=checkbox]');
for (i = 0; i < boxes.length; i++) {
if (boxes[i].checked) out.push(i);
}
} else if (el.tagName === 'SELECT') {
for (i = 0; i < el.options.length; i++) {
if (el.options[i].selected) out.push(i);
}
}
return out.join(',');
};
// Bitmap content as a PNG data URL: <img> roots directly; buttons get a
// leading <img> child (note: wxDomSetText replaces children — C++ must
// set the image after the label). Explicit w/h from the wx bitmap size:
// images load asynchronously, so without them the measurement clone (and
// hence DoGetBestSize) would see a 0x0 image.
window.wxDomSetImage = function (domId, dataUrl, w, h) {
var el = controls.get(domId);
if (!el) return;
var img;
if (el.tagName === 'IMG') {
img = el;
} else {
img = el.querySelector('img.wx-btn-img');
if (!img) {
img = document.createElement('img');
img.className = 'wx-btn-img';
// keep the bitmap from being squashed inside the flex button
img.style.flexShrink = '0';
el.insertBefore(img, el.firstChild);
}
}
if (w > 0) { img.width = w; img.style.width = w + 'px'; }
if (h > 0) { img.height = h; img.style.height = h + 'px'; }
img.src = dataUrl;
};
window.wxDomSetEnabled = function (domId, enabled) {
var root = controls.get(domId);
var el = inputs.get(domId) || root;
if (!el) return;
if ('disabled' in el) el.disabled = !enabled;
if (root) root.style.opacity = enabled ? '' : '0.5';
};
window.wxDomSetReadOnly = function (domId, readOnly) {
var el = inputs.get(domId) || controls.get(domId);
if (el) el.readOnly = !!readOnly;
};
window.wxDomSetShown = function (domId, shown) {
var el = controls.get(domId);
if (el) el.style.display = shown ? (el.dataset.wxDisplay || '') : 'none';
};
window.wxDomFocus = function (domId) {
var el = inputs.get(domId) || controls.get(domId);
if (el && document.activeElement !== el) el.focus();
};
window.wxDomSetFont = function (domId, cssFont) {
var el = controls.get(domId);
if (el && cssFont) el.style.font = cssFont;
};
window.wxDomSetAriaLabel = function (domId, label) {
var el = controls.get(domId);
if (el) el.setAttribute('aria-label', label);
};
window.wxDomSetTooltip = function (domId, tip) {
var el = controls.get(domId);
if (el) el.title = tip;
};
// Insets in CSS px relative to the element's own box; all <= 0 clears.
// clip-path does not affect layout and clips hit-testing too, so rows
// scrolled out of a pane neither paint nor catch clicks.
window.wxDomSetClip = function (domId, top, right, bottom, left) {
var el = controls.get(domId);
if (!el) return;
if (top <= 0 && right <= 0 && bottom <= 0 && left <= 0) {
if (el.style.clipPath) el.style.clipPath = '';
} else {
el.style.clipPath = 'inset(' + Math.max(0, top) + 'px ' +
Math.max(0, right) + 'px ' +
Math.max(0, bottom) + 'px ' +
Math.max(0, left) + 'px)';
}
};
// ========== Menus & toolbars ==========
var openMenuPopup = null;
function closeMenuPopup() {
if (openMenuPopup) {
openMenuPopup.remove();
openMenuPopup = null;
}
}
document.addEventListener('mousedown', function (ev) {
// any click outside an open menu closes it (mousedown so the click on
// another control still lands)
if (openMenuPopup && !openMenuPopup.contains(ev.target)) {
var inTitle = ev.target.closest && ev.target.closest('.wx-menu-title');
if (!inTitle) closeMenuPopup();
}
});
function registryRegister(id, info) {
var reg = window.wxElementRegistry;
if (reg && reg.registerRendered) reg.registerRendered(id, info);
}
function rectInfo(el) {
var r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height,
centerX: r.x + r.width / 2, centerY: r.y + r.height / 2 };
}
// Builds the item rows of a menu popup into `pop` (shared by the menubar
// popups and the standalone context menu). items:
// [{id,label,kind:'normal'|'separator'|'check'|'radio'|'submenu',
// checked,enabled,items}]. onChoose(id) fires for a leaf row click;
// reopenSubmenu(row, subItems) handles a submenu row. Each row is
// published to the e2e registry under registryParent as 'menuitem'.
function buildMenuItemRows(pop, items, registryParent, onChoose, reopenSubmenu) {
items.forEach(function (it, idx) {
if (it.kind === 'separator') {
var sep = document.createElement('div');
sep.style.cssText = 'border-top:1px solid #808080;margin:2px 4px;';
pop.appendChild(sep);
return;
}
var row = document.createElement('div');
row.textContent = (it.checked ? '✓ ' : ' ') + it.label +
(it.kind === 'submenu' ? ' ▸' : '');
row.style.cssText = 'padding:2px 14px 2px 6px;cursor:default;' +
(it.enabled ? '' : 'color:#808080;');
if (it.enabled) {
row.addEventListener('mouseenter', function () {
row.style.background = '#000080';
row.style.color = '#ffffff';
});
row.addEventListener('mouseleave', function () {
row.style.background = '';
row.style.color = '';
});
row.addEventListener('click', function (ev) {
ev.stopPropagation();
if (it.kind === 'submenu') {
reopenSubmenu(row, it.items || []);
return;
}
onChoose(it.id);
});
}
pop.appendChild(row);
// register popup items for the e2e registry (canvas parity)
requestAnimationFrame(function () {
if (!pop.isConnected) return;
registryRegister(registryParent + ':menuitem:' + idx, Object.assign({
elementType: 'menuitem',
subType: it.kind === 'check' || it.kind === 'radio' ? it.kind : 'normal',
label: it.label, tooltip: '', enabled: !!it.enabled,
parentId: registryParent, index: idx
}, rectInfo(row)));
});
});
}
// Builds and shows the popup for one menubar menu's items below `anchor`.
function showMenuPopup(domId, anchor, items, registryParent) {
closeMenuPopup();
var pop = document.createElement('div');
pop.className = 'wx-menu-popup';
var a = anchor.getBoundingClientRect();
pop.style.cssText =
'position:absolute;z-index:10000;background:#d4d0c8;' +
'border:1px solid #808080;box-shadow:2px 2px 4px rgba(0,0,0,.3);' +
'padding:2px;white-space:pre;min-width:120px;' +
'left:' + (a.left + window.scrollX) + 'px;' +
'top:' + (a.bottom + window.scrollY) + 'px;';
pop.style.font = anchor.style.font || getComputedStyle(anchor).font;
buildMenuItemRows(pop, items, registryParent,
function (id) {
var bar = controls.get(domId);
if (bar) bar.dataset.wxLastCommand = String(id);
closeMenuPopup();
dispatch(domId, EVT.MENU);
},
function (row, subItems) {
// simple inline expansion: replace popup with the submenu
showMenuPopup(domId, row, subItems, registryParent);
});
document.body.appendChild(pop);
openMenuPopup = pop;
}
// ========== Context menu (wxWindow::PopupMenu -> DoPopupMenu) ==========
//
// Shows a standalone popup at a viewport point and BLOCKS the synchronous
// C++ DoPopupMenu via the same ProcessEvents pump wxDialog::ShowModal uses
// (src/wasm/dialog.cpp): the EM_ASYNC_JS wxDomPopupMenuModal in
// src/wasm/window.cpp awaits the returned Promise, which resolves with the
// chosen command id (-1 = cancelled). json: the wxMenu serialized by
// wxMenu::WasmItemsToJson(). x/y in viewport px, or -1 (wxDefaultCoord) to
// use the last pointer position (the KiCad canvas right-click case).
Module['wxShowContextMenu'] = function (json, invokerDomId, x, y) {
var items;
try {
items = JSON.parse(json);
} catch (e) {
console.error('wxShowContextMenu: bad JSON: ' + e.message);
return Promise.resolve(-1);
}
var vx = x, vy = y;
if (x === -1 || y === -1) {
vx = lastPointerClientX;
vy = lastPointerClientY;
} else {
var inv = controls.get(invokerDomId);
if (inv) {
var ir = inv.getBoundingClientRect();
vx = ir.left + x; vy = ir.top + y;
} else if (Module['canvas']) {
var cr = Module['canvas'].getBoundingClientRect();
vx = cr.left + x; vy = cr.top + y;
}
}
closeMenuPopup(); // a context menu supersedes any open menubar popup
var pop = document.createElement('div');
pop.className = 'wx-menu-popup';
pop.style.cssText =
'position:fixed;z-index:10000;background:#d4d0c8;' +
'border:1px solid #808080;box-shadow:2px 2px 4px rgba(0,0,0,.3);' +
'padding:2px;white-space:pre;min-width:120px;left:0;top:0;';
if (Module['canvas']) {
pop.style.font = getComputedStyle(Module['canvas']).font;
}
return new Promise(function (resolve) {
var settled = false;
function settle(id) {
if (settled) return;
settled = true;
document.removeEventListener('mousedown', onOutside, true);
document.removeEventListener('keydown', onKey, true);
if (pop.parentNode) pop.remove();
if (openMenuPopup === pop) openMenuPopup = null;
// Drop the popup's e2e-registry entries so the dismissal is observable
// (the rows are gone from the DOM; their geometry is now stale).
var reg = window.wxElementRegistry;
if (reg && reg.unregisterRenderedByParent)
reg.unregisterRenderedByParent('popupmenu');
resolve(id);
}
function onOutside(ev) { if (!pop.contains(ev.target)) settle(-1); }
function onKey(ev) {
if (ev.key === 'Escape') { ev.stopPropagation(); settle(-1); }
}
// reopenSubmenu rebuilds the rows in place for a chosen submenu.
function makeReopen() {
return function (row, subItems) {
pop.textContent = '';
buildMenuItemRows(pop, subItems, 'popupmenu',
function (id) { settle(id); }, makeReopen());
};
}
buildMenuItemRows(pop, items, 'popupmenu',
function (id) { settle(id); }, makeReopen());
document.body.appendChild(pop);
openMenuPopup = pop;
// Clamp to the viewport: flip left/up when overflowing an edge.
var w = pop.offsetWidth, h = pop.offsetHeight;
var L = vx, T = vy;
if (L + w > window.innerWidth) L = Math.max(0, vx - w);
if (T + h > window.innerHeight) T = Math.max(0, vy - h);
pop.style.left = L + 'px';
pop.style.top = T + 'px';
document.addEventListener('mousedown', onOutside, true);
document.addEventListener('keydown', onKey, true);
// Pump wx while the popup is open so the suspended coroutine stack
// (DoPopupMenu is called from inside a tool) stays parked and the app
// keeps painting. Must NEVER stop without resolving (a pending Promise
// would freeze the parked stack) — any error cancels the menu loudly.
(function pump() {
if (settled) return;
setTimeout(function () {
if (settled) return;
var p;
try {
p = Module['ccall']('ProcessEvents', 'void', [], [], { async: true });
} catch (e) {
console.error('[wxWasm] context menu pump error: ' + e);
settle(-1);
return;
}
Promise.resolve(p).then(
function () { if (!settled) pump(); },
function (e) {
console.error('[wxWasm] context menu pump error: ' + e);
settle(-1);
});
}, 17);
})();
});
};
// structureJson: [{title, items:[...]}, ...] (schema above)
window.wxDomMenuSetStructure = function (domId, structureJson) {
var el = controls.get(domId);
if (!el || !el.dataset.wxMenuBar) return;
var menus;
try {
menus = JSON.parse(structureJson);
} catch (e) {
console.error('wxDomMenuSetStructure: bad JSON: ' + e.message);
return;
}
el.textContent = '';
closeMenuPopup();
menus.forEach(function (m, idx) {
var btn = document.createElement('button');
btn.className = 'wx-menu-title';
btn.textContent = m.title;
btn.style.cssText =
'border:none;background:transparent;padding:2px 8px;margin:0;' +
'font:inherit;white-space:pre;';
btn.addEventListener('mousedown', function (ev) { ev.stopPropagation(); });
btn.addEventListener('click', function (ev) {
ev.stopPropagation();
if (openMenuPopup) {
closeMenuPopup();
} else {
showMenuPopup(domId, btn, m.items || [], domId + ':' + idx);
}
});
el.appendChild(btn);
requestAnimationFrame(function () {
registryRegister(domId + ':menubartitle:' + idx, Object.assign({
elementType: 'menuitem', subType: 'menubar',
label: m.title, tooltip: '', enabled: true,
parentId: String(domId), index: idx
}, rectInfo(btn)));
});
});
};
// tools: [{id,label,tooltip,kind:'button'|'toggle'|'separator',
// toggled,enabled,img,imgW,imgH}]
window.wxDomToolbarSetTools = function (domId, toolsJson) {
var el = controls.get(domId);
if (!el || !el.dataset.wxToolBar) return;
var tools;
try {
tools = JSON.parse(toolsJson);
} catch (e) {
console.error('wxDomToolbarSetTools: bad JSON', e);
return;
}
el.textContent = '';
tools.forEach(function (t, idx) {
if (t.kind === 'separator') {
var sep = document.createElement('div');
sep.style.cssText =
'border-left:1px solid #808080;align-self:stretch;margin:1px 3px;';
el.appendChild(sep);
return;
}
var btn = document.createElement('button');
btn.className = 'wx-tool';
// same look as every other tooltip (no native title attribute)
tooltipHover(btn, function () { return t.tooltip || t.label || ''; });
btn.style.cssText = 'padding:1px 3px;margin:1px;font:inherit;' +
'display:flex;align-items:center;';
if (t.img) {
var img = document.createElement('img');
if (t.imgW > 0) { img.width = t.imgW; img.style.width = t.imgW + 'px'; }
if (t.imgH > 0) { img.height = t.imgH; img.style.height = t.imgH + 'px'; }
img.style.flexShrink = '0';
img.src = t.img;
btn.appendChild(img);
} else {
btn.textContent = t.label || '';
}
btn.disabled = !t.enabled;
if (t.toggled) btn.style.background = '#b0c4de';
btn.addEventListener('click', function (ev) {
ev.stopPropagation();
el.dataset.wxLastCommand = String(t.id);
dispatch(domId, EVT.TOOL);
});
el.appendChild(btn);
requestAnimationFrame(function () {
if (!btn.isConnected) return;
registryRegister(domId + ':tool:' + idx, Object.assign({
elementType: 'tool', subType: t.kind === 'toggle' ? 'toggle' : 'button',
label: t.label || '', tooltip: t.tooltip || '', enabled: !!t.enabled,
parentId: String(domId), index: idx, toggled: !!t.toggled
}, rectInfo(btn)));
});
});
};
// Command id of the last activated menu item / tool (set by the click
// handlers above; read by wxMenuBar/wxToolBar OnDomEvent).
window.wxDomGetLastCommandId = function (domId) {
var el = controls.get(domId);
var v = el ? parseInt(el.dataset.wxLastCommand, 10) : NaN;
return isNaN(v) ? -1 : v;
};
// ========== Notebook tab strip ==========
// (Re-)register the strip's tabs in the e2e registry: elementType 'tab',
// subType 'selected'/'button' — the same contract the canvas port's
// notebook keeps, so clickTab() works unchanged.
function scheduleTabRegistry(domId, el) {
requestAnimationFrame(function () {
var reg = window.wxElementRegistry;
if (!reg || !el.isConnected || !el._wxTabs) return;
var stale = [];
reg.renderedElements.forEach(function (info, key) {
if (String(key).indexOf(domId + ':tab:') === 0) stale.push(key);
});
stale.forEach(function (key) { reg.unregisterRendered(key); });
var buttons = el.querySelectorAll('.wx-tab-strip > button');
el._wxTabs.forEach(function (tab, idx) {
var btn = buttons[idx];
if (!btn) return;
registryRegister(domId + ':tab:' + idx, Object.assign({
elementType: 'tab',
subType: tab.selected ? 'selected' : 'button',
label: tab.label, tooltip: '', enabled: true,
parentId: String(domId), index: idx
}, rectInfo(btn)));
});
});
}
// Mirror DOM-native controls the canvas port used to publish from its
// paint hooks as "rendered elements": spin arrows ('spinbutton',
// subType up/down) and text fields ('textctrl', subType
// singleline/multiline). Keeps clickSpinUp()/findSingleLineTextCtrl()
// and friends working against the same registry contract.
function scheduleControlRegistry(domId, el) {
requestAnimationFrame(function () {
var reg = window.wxElementRegistry;
if (!reg || !el.isConnected) return;
var stale = [];
reg.renderedElements.forEach(function (info, key) {
var k = String(key);
if (k.indexOf(domId + ':spinbutton:') === 0 ||
k.indexOf(domId + ':textctrl:') === 0) stale.push(key);
});
stale.forEach(function (key) { reg.unregisterRendered(key); });
if (el.dataset.wxSpin) {
var arrows = [
{ sel: '.wx-spin-up', sub: 'up', idx: 0 },
{ sel: '.wx-spin-down', sub: 'down', idx: 1 }
];
arrows.forEach(function (a) {
var btn = el.querySelector(a.sel);
if (!btn) return;
registryRegister(domId + ':spinbutton:' + a.idx, Object.assign({
elementType: 'spinbutton', subType: a.sub,
label: '', tooltip: '', enabled: !btn.disabled,
parentId: String(domId), index: a.idx
}, rectInfo(btn)));
});
return;
}
// text fields: plain <input type=text|password> (a datalist combo
// is not a textctrl) and <textarea>
var isText = el.tagName === 'TEXTAREA' ||
(el.tagName === 'INPUT' &&
(el.type === 'text' || el.type === 'password') &&
!el.hasAttribute('list'));
if (isText) {
registryRegister(domId + ':textctrl:0', Object.assign({
elementType: 'textctrl',
subType: el.tagName === 'TEXTAREA' ? 'multiline' : 'singleline',
label: el.getAttribute('aria-label') || '',
tooltip: '', enabled: !el.disabled,
parentId: String(domId), index: 0
}, rectInfo(el)));
}
});
}
// tabsJson: [{label, selected}] — tab id == array index.
window.wxDomNotebookSetTabs = function (domId, tabsJson) {
var el = controls.get(domId);
if (!el || !el.dataset.wxNotebook) return;
var tabs;
try {
tabs = JSON.parse(tabsJson);
} catch (e) {
console.error('wxDomNotebookSetTabs: bad JSON: ' + e.message);
return;
}
var strip = el.querySelector('.wx-tab-strip');
if (!strip) return;
strip.textContent = '';
tabs.forEach(function (tab, idx) {
var btn = document.createElement('button');
btn.setAttribute('role', 'tab');
btn.setAttribute('aria-selected', tab.selected ? 'true' : 'false');
btn.textContent = tab.label;
btn.style.cssText =
'font:inherit;margin:1px 0 0 1px;padding:2px 8px;' +
'border:1px solid #808080;border-bottom:none;' +
'border-radius:3px 3px 0 0;white-space:pre;' +
'overflow:hidden;text-overflow:ellipsis;' +
'flex:0 1 auto;min-width:0;cursor:default;' +
(tab.selected
? 'background:#f5f4f2;font-weight:bold;position:relative;top:1px;'
: 'background:#c8c4bc;');
btn.addEventListener('click', function (ev) {
ev.stopPropagation();
el.dataset.wxLastCommand = String(idx);
dispatch(domId, EVT.TAB);
});
strip.appendChild(btn);
});
el._wxTabs = tabs;
scheduleTabRegistry(domId, el);
};
// Tab strip height for the C++ client-area math; clone-measured so it
// works while the notebook (or an ancestor) is display:none.
window.wxDomNotebookStripHeight = function (domId) {
var el = controls.get(domId);
if (!el) return 0;
var strip = el.querySelector('.wx-tab-strip');
if (!strip) return 0;
var r = strip.getBoundingClientRect();
if (r.height > 0) return Math.ceil(r.height);
// hidden: measure a clone off-screen
var clone = strip.cloneNode(true);
clone.style.position = 'absolute';
clone.style.left = '-100000px';
clone.style.top = '0';
clone.style.right = 'auto';
clone.style.visibility = 'hidden';
clone.style.display = 'flex';
document.body.appendChild(clone);
var h = clone.getBoundingClientRect().height;
clone.remove();
return Math.max(0, Math.ceil(h));
};
// Intrinsic (content-driven) size, packed (w << 16) | h for EM_ASM_INT.
// Measured on a CLONE inside an always-rendered offscreen host: sizers run
// DoGetBestSize before the frame is shown, when the element (or any
// ancestor TLW div) is display:none and would measure 0x0.
var measureHost = null;
window.wxDomIntrinsicSize = function (domId) {
var el = controls.get(domId);
if (!el) return 0;
if (!measureHost) {
measureHost = document.createElement('div');
measureHost.style.cssText =
'position:absolute;left:-100000px;top:0;visibility:hidden;';
document.body.appendChild(measureHost);
}
var clone = el.cloneNode(true); // copies inline styles incl. font
clone.style.display = el.dataset.wxDisplay || 'block';
clone.style.position = 'static';
clone.style.width = 'auto';
clone.style.height = 'auto';
// shrink-to-fit so the width reflects the content, not the host
clone.style.inlineSize = 'fit-content';
measureHost.appendChild(clone);
var rect = clone.getBoundingClientRect();
clone.remove();
var w = Math.min(0xffff, Math.max(1, Math.ceil(rect.width)));
var h = Math.min(0xffff, Math.max(1, Math.ceil(rect.height)));
return (w << 16) | h;
};
// ========== Tooltip layer ==========
//
// One port-rendered tooltip for everything: DOM-backed widgets AND
// canvas-island widgets (which have no element to carry a title
// attribute). Driven from C++ (src/wasm/tooltip.cpp) off the mouse
// pipeline's hover hit-test; toolbar buttons/menu titles use the
// JS-side tooltipHover() since they aren't wx windows.
var tooltipEl = null;
var tooltipHoverTimer = null;
function ensureTooltipEl() {
if (!tooltipEl) {
tooltipEl = document.createElement('div');
tooltipEl.id = 'wx-tooltip';
tooltipEl.style.cssText =
'position:fixed;z-index:20000;display:none;' +
'background:#ffffe1;color:#000;border:1px solid #000;' +
'padding:2px 4px;font:12px sans-serif;white-space:pre;' +
'pointer-events:none;max-width:400px;';
document.body.appendChild(tooltipEl);
}
return tooltipEl;
}
// x/y: #canvas-relative (wx screen) coordinates.
window.wxDomTooltipShow = function (text, x, y) {
if (!text) return;
var el = ensureTooltipEl();
var c = Module['canvas'];
var base = c ? c.getBoundingClientRect() : { left: 0, top: 0 };
el.textContent = text;
el.style.display = 'block';
var px = base.left + x + 2;
var py = base.top + y + 18;
el.style.left = '0px';
el.style.top = '0px';
var r = el.getBoundingClientRect();
if (px + r.width > window.innerWidth - 4) {
px = Math.max(4, window.innerWidth - r.width - 4);
}
if (py + r.height > window.innerHeight - 4) {
py = base.top + y - r.height - 6;
}
el.style.left = px + 'px';
el.style.top = py + 'px';
};
window.wxDomTooltipHide = function () {
if (tooltipEl) tooltipEl.style.display = 'none';
if (tooltipHoverTimer) {
clearTimeout(tooltipHoverTimer);
tooltipHoverTimer = null;
}
};
// Any press/keystroke/scroll dismisses the tooltip (capture phase so
// stopPropagation in control listeners can't keep it alive).
['mousedown', 'keydown', 'wheel'].forEach(function (evName) {
document.addEventListener(evName, function () {
if (tooltipEl && tooltipEl.style.display !== 'none') {
tooltipEl.style.display = 'none';
}
}, true);
});
// Hover tooltips for JS-built surfaces that aren't wx windows
// (toolbar tool buttons). getText is read at fire time.
function tooltipHover(el, getText) {
el.addEventListener('mouseenter', function (ev) {
if (tooltipHoverTimer) clearTimeout(tooltipHoverTimer);
tooltipHoverTimer = setTimeout(function () {
var text = getText();
if (!text) return;
var c = Module['canvas'];
var base = c ? c.getBoundingClientRect() : { left: 0, top: 0 };
window.wxDomTooltipShow(text,
Math.round(ev.clientX - base.left),
Math.round(ev.clientY - base.top));
}, 600);
});
el.addEventListener('mouseleave', function () {
window.wxDomTooltipHide();
});
}
// ========== Input forwarding: DOM layer → wx pipeline ==========
//
// The wx mouse pipeline (hit-testing, ENTER/LEAVE hover synthesis,
// wheel scrolling, capture) is fed by Emscripten callbacks on #canvas.
// DOM controls swallow browser events before #canvas sees them, so the
// pipeline goes blind whenever the pointer is over a DOM element.
// These document-level listeners (bubble phase) forward the events wx
// needs into wx_dom_mouse (src/wasm/domevents.cpp), which re-enters the
// SAME C++ path.
//
// Invariants (prevent double dispatch):
// - events targeting #canvas take only the Emscripten path;
// - events targeting DOM controls take only this path;
// - LEFT clicks on interactive controls take only the native control
// path (their click listeners + wx_dom_event); we forward left
// clicks only for passive controls (dataset.wxPassive: statictext,
// gauge), middle/right always.
// NOTE: listeners that stopPropagation on mousedown (menubar titles)
// intentionally opt out of forwarding.
var canvasRect = null;
window.addEventListener('resize', function () { canvasRect = null; });
function wxForwardMouse(ev, kind, deltaY) {
var c = Module['canvas'];
if (!c) return 0;
if (!canvasRect) canvasRect = c.getBoundingClientRect();
var mods = (ev.ctrlKey ? 1 : 0) | (ev.shiftKey ? 2 : 0) |
(ev.altKey ? 4 : 0) | (ev.metaKey ? 8 : 0);
try {
return Module['ccall']('wx_dom_mouse', 'number',
['number', 'number', 'number', 'number',
'number', 'number', 'number', 'number'],
[kind,
Math.round(ev.clientX - canvasRect.left),
Math.round(ev.clientY - canvasRect.top),
ev.button | 0, ev.buttons | 0, ev.detail | 0, mods, deltaY || 0]);
} catch (e) {
return 0;
}
}
function forwardTarget(ev) {
var t = ev.target;
if (!t || t === Module['canvas'] || !t.closest) return null;
if (t.closest('.wx-menu-popup')) return null;
return t.closest('.wx-dom-control');
}
// Track the pointer everywhere (capture phase, including over the GAL
// canvas where forwardTarget is null) so a "popup at the mouse" context
// menu lands at the cursor.
function trackPointer(ev) {
lastPointerClientX = ev.clientX;
lastPointerClientY = ev.clientY;
}
document.addEventListener('mousemove', trackPointer, true);
document.addEventListener('mousedown', trackPointer, true);
document.addEventListener('contextmenu', trackPointer, true);
document.addEventListener('mousemove', function (ev) {
if (forwardTarget(ev)) wxForwardMouse(ev, 1, 0);
});
document.addEventListener('mousedown', function (ev) {
var ctl = forwardTarget(ev);
if (!ctl) return;
if (ev.button !== 0 || ctl.dataset.wxPassive) wxForwardMouse(ev, 2, 0);
});
document.addEventListener('mouseup', function (ev) {
var ctl = forwardTarget(ev);
if (!ctl) return;
if (ev.button !== 0 || ctl.dataset.wxPassive) wxForwardMouse(ev, 3, 0);
});
document.addEventListener('wheel', function (ev) {
var ctl = forwardTarget(ev);
if (!ctl) return;
// A natively scrollable element under the cursor (textarea, multi-
// select, checklist) keeps its own wheel behavior.
for (var n = ev.target; n; n = n.parentElement) {
var oy = getComputedStyle(n).overflowY;
if ((oy === 'auto' || oy === 'scroll') &&
n.scrollHeight > n.clientHeight) {
return;
}
if (n === ctl) break;
}
if (wxForwardMouse(ev, 4, ev.deltaY)) ev.preventDefault();
}, { passive: false });
document.addEventListener('contextmenu', function (ev) {
var ctl = forwardTarget(ev);
if (!ctl) return;
var t = ev.target;
var editable = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
t.isContentEditable);
// wx already received the right-click via the forwarded mousedown/up;
// suppress the browser menu except over editables (keep native paste).
if (!editable) ev.preventDefault();
});
})();