dom.mjs raw

   1  // TinyJS Runtime — DOM Bridge
   2  // Provides Go-callable browser DOM operations.
   3  // Elements are tracked by integer handles.
   4  // Function names match Go signatures (PascalCase).
   5  
   6  
   7  const _elements = new Map();
   8  const _nodeToId = new WeakMap();
   9  const _elemCallbacks = new Map();
  10  const _cbEventInfo = new Map(); // cbId -> {el, event, fn}
  11  const _resizeObservers = new Map(); // elId -> ResizeObserver
  12  let _nextId = 1; // Safe up to 2^53 in JS. No wrap needed.
  13  const _callbacks = new Map();
  14  let _nextCb = 1;
  15  let _onCallbacksReleased = null;
  16  export function setCallbackReleaseHook(fn) { _onCallbacksReleased = fn; }
  17  
  18  // Instrumentation: log _elements.size at thresholds when __moxie_instrument is set.
  19  const _elemThresholds = [100, 500, 1000, 2000, 5000];
  20  let _elemThresholdIdx = 0;
  21  function _checkElemThreshold() {
  22    if (!globalThis.__moxie_instrument) return;
  23    const sz = _elements.size;
  24    while (_elemThresholdIdx < _elemThresholds.length && sz >= _elemThresholds[_elemThresholdIdx]) {
  25      const log = globalThis.__moxie_alloc_log;
  26      if (Array.isArray(log)) {
  27        if (log.length >= 10000) log.shift();
  28        log.push({ ts: Date.now(), source: 'dom', metric: 'elements_size', value: sz });
  29      }
  30      _elemThresholdIdx++;
  31    }
  32  }
  33  
  34  // Initialize the ring buffer on first use if instrumentation is active.
  35  if (typeof globalThis.__moxie_alloc_log === 'undefined') {
  36    globalThis.__moxie_alloc_log = [];
  37  }
  38  
  39  // Register a new element handle and return its ID.
  40  function _trackEl(el) {
  41    const id = _nextId++;
  42    _elements.set(id, el);
  43    _nodeToId.set(el, id);
  44    _checkElemThreshold();
  45    return id;
  46  }
  47  
  48  // Bootstrap: map handle 0 to document.body (available after DOMContentLoaded).
  49  function ensureBody() {
  50    if (!_elements.has(0) && typeof document !== 'undefined' && document.body) {
  51      _elements.set(0, document.body);
  52    }
  53  }
  54  
  55  // Element creation and lookup.
  56  export function CreateElement(tag) {
  57    return _trackEl(document.createElement(tag));
  58  }
  59  
  60  export function CreateTextNode(text) {
  61    return _trackEl(document.createTextNode(text));
  62  }
  63  
  64  export function GetElementById(id) {
  65    const el = document.getElementById(id);
  66    return el ? _trackEl(el) : 0;
  67  }
  68  
  69  export function QuerySelector(sel) {
  70    const el = document.querySelector(sel);
  71    return el ? _trackEl(el) : 0;
  72  }
  73  
  74  export function QuerySelectorFrom(rootId, sel) {
  75    const root = _elements.get(rootId);
  76    if (!root) return 0;
  77    const el = root.querySelector(sel);
  78    return el ? _trackEl(el) : 0;
  79  }
  80  
  81  export function Body() {
  82    ensureBody();
  83    return 0;
  84  }
  85  
  86  // Tree manipulation.
  87  export function AppendChild(parentId, childId) {
  88    const parent = _elements.get(parentId);
  89    const child = _elements.get(childId);
  90    if (parent && child) parent.appendChild(child);
  91  }
  92  
  93  export function RemoveChild(parentId, childId) {
  94    const parent = _elements.get(parentId);
  95    const child = _elements.get(childId);
  96    if (parent && child) parent.removeChild(child);
  97  }
  98  
  99  export function Remove(elId) {
 100    const el = _elements.get(elId);
 101    if (el && el.parentNode) el.parentNode.removeChild(el);
 102  }
 103  
 104  export function FirstChild(parentId) {
 105    const parent = _elements.get(parentId);
 106    return (parent && parent.firstChild) ? _trackEl(parent.firstChild) : 0;
 107  }
 108  
 109  export function FirstElementChild(parentId) {
 110    const parent = _elements.get(parentId);
 111    return (parent && parent.firstElementChild) ? _trackEl(parent.firstElementChild) : 0;
 112  }
 113  
 114  export function InsertBefore(parentId, newId, refId) {
 115    const parent = _elements.get(parentId);
 116    const newEl = _elements.get(newId);
 117    const ref = refId >= 0 ? _elements.get(refId) : null;
 118    if (parent && newEl) parent.insertBefore(newEl, ref);
 119  }
 120  
 121  export function ReplaceChild(parentId, newId, oldId) {
 122    const parent = _elements.get(parentId);
 123    const newEl = _elements.get(newId);
 124    const oldEl = _elements.get(oldId);
 125    if (parent && newEl && oldEl) parent.replaceChild(newEl, oldEl);
 126  }
 127  
 128  // Contenteditable support — makes contenteditable divs behave like textareas
 129  // for Moxie code that reads/writes "value", "selectionStart", "selectionEnd".
 130  
 131  let _cePlaceholderCSS = false;
 132  
 133  function _ceSetup(el) {
 134    if (!_cePlaceholderCSS) {
 135      const s = document.createElement('style');
 136      s.textContent = '[data-placeholder]:empty::before{content:attr(data-placeholder);color:var(--muted);pointer-events:none;}[data-placeholder] br:only-child{display:none;}';
 137      document.head.appendChild(s);
 138      _cePlaceholderCSS = true;
 139    }
 140    el.style.outline = 'none';
 141    const ph = el.getAttribute('placeholder');
 142    if (ph) { el.setAttribute('data-placeholder', ph); el.removeAttribute('placeholder'); }
 143    // Firefox bug 685799: Backspace/Delete can't remove contenteditable=false
 144    // inline elements. Detect deletion adjacent to a chip and remove it manually.
 145    el.addEventListener('beforeinput', function(e) {
 146      if (e.inputType !== 'deleteContentBackward' && e.inputType !== 'deleteContentForward') return;
 147      const sel = window.getSelection();
 148      if (!sel.rangeCount || !sel.isCollapsed) return;
 149      const r = sel.getRangeAt(0);
 150      let target = null;
 151      if (e.inputType === 'deleteContentBackward') {
 152        if (r.startContainer === el) {
 153          const prev = el.childNodes[r.startOffset - 1];
 154          if (prev && prev.dataset && prev.dataset.npub) target = prev;
 155        } else if (r.startContainer.nodeType === 3 && r.startOffset === 0) {
 156          const prev = r.startContainer.previousSibling;
 157          if (prev && prev.dataset && prev.dataset.npub) target = prev;
 158        }
 159      } else {
 160        if (r.startContainer === el) {
 161          const next = el.childNodes[r.startOffset];
 162          if (next && next.dataset && next.dataset.npub) target = next;
 163        } else if (r.startContainer.nodeType === 3 && r.startOffset === r.startContainer.textContent.length) {
 164          const next = r.startContainer.nextSibling;
 165          if (next && next.dataset && next.dataset.npub) target = next;
 166        }
 167      }
 168      if (target) {
 169        e.preventDefault();
 170        target.remove();
 171        el.dispatchEvent(new Event('input', { bubbles: true }));
 172      }
 173    });
 174  }
 175  
 176  function _isCE(el) { return el && (el.contentEditable === 'true' || el.contentEditable === 'plaintext-only'); }
 177  
 178  function _ceExtract(el, stopNode, stopOff) {
 179    let text = '';
 180    let stopped = false;
 181    let resultOff = 0;
 182    function walk(node) {
 183      if (stopped) return;
 184      if (stopNode && node === stopNode) {
 185        if (node.nodeType === 3) {
 186          text += node.textContent.substring(0, stopOff);
 187        } else {
 188          let c = 0;
 189          for (const ch of node.childNodes) { if (c >= stopOff) break; walk(ch); c++; }
 190        }
 191        resultOff = text.length;
 192        stopped = true;
 193        return;
 194      }
 195      if (node.nodeType === 3) { text += node.textContent; }
 196      else if (node.nodeName === 'BR') { text += '\n'; }
 197      else if (node.dataset && node.dataset.npub) { text += 'nostr:' + node.dataset.npub; }
 198      else {
 199        if ((node.nodeName === 'DIV' || node.nodeName === 'P') && text.length > 0 && text[text.length - 1] !== '\n') text += '\n';
 200        for (const ch of node.childNodes) { if (stopped) return; walk(ch); }
 201      }
 202    }
 203    for (const ch of el.childNodes) { if (stopped) break; walk(ch); }
 204    if (!stopped) resultOff = text.length;
 205    return { text, offset: resultOff };
 206  }
 207  
 208  function _ceGetValue(el) { return _ceExtract(el).text; }
 209  
 210  function _ceGetSelOff(el) {
 211    const sel = window.getSelection();
 212    if (!sel.rangeCount || !el.contains(sel.focusNode)) return 0;
 213    return _ceExtract(el, sel.focusNode, sel.focusOffset).offset;
 214  }
 215  
 216  function _ceSetSelOff(el, target) {
 217    let rem = target;
 218    let rNode = el, rOff = 0;
 219    let done = false;
 220    function walk(node) {
 221      if (done) return;
 222      if (node.nodeType === 3) {
 223        if (rem <= node.textContent.length) { rNode = node; rOff = rem; done = true; return; }
 224        rem -= node.textContent.length;
 225      } else if (node.nodeName === 'BR') {
 226        if (rem <= 1) { rNode = node.parentNode; rOff = Array.from(node.parentNode.childNodes).indexOf(node) + 1; done = true; return; }
 227        rem -= 1;
 228      } else if (node.dataset && node.dataset.npub) {
 229        const len = ('nostr:' + node.dataset.npub).length;
 230        if (rem <= len) { rNode = node.parentNode; rOff = Array.from(node.parentNode.childNodes).indexOf(node) + 1; done = true; return; }
 231        rem -= len;
 232      } else {
 233        if ((node.nodeName === 'DIV' || node.nodeName === 'P') && rem > 0) rem--;
 234        for (const ch of node.childNodes) { if (done) return; walk(ch); }
 235      }
 236    }
 237    for (const ch of el.childNodes) { if (done) break; walk(ch); }
 238    if (!done) { rNode = el; rOff = el.childNodes.length; }
 239    try {
 240      const sel = window.getSelection();
 241      const range = document.createRange();
 242      range.setStart(rNode, rOff);
 243      range.collapse(true);
 244      sel.removeAllRanges();
 245      sel.addRange(range);
 246    } catch(e) {}
 247  }
 248  
 249  function _ceSetValue(el, text) {
 250    while (el.firstChild) el.removeChild(el.firstChild);
 251    if (!text) return;
 252    const re = /(nostr:npub1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})/;
 253    const parts = text.split(re);
 254    for (const part of parts) {
 255      if (part.match(re)) {
 256        el.appendChild(_ceChip(part.slice(6), '', ''));
 257      } else {
 258        const lines = part.split('\n');
 259        for (let i = 0; i < lines.length; i++) {
 260          if (i > 0) el.appendChild(document.createElement('br'));
 261          if (lines[i]) el.appendChild(document.createTextNode(lines[i]));
 262        }
 263      }
 264    }
 265  }
 266  
 267  function _ceChip(npub, name, pic) {
 268    const s = document.createElement('span');
 269    s.contentEditable = 'false';
 270    s.dataset.npub = npub;
 271    s.style.cssText = 'display:inline-flex;align-items:center;gap:3px;background:rgba(100,100,255,0.15);border-radius:4px;padding:1px 5px;font-size:13px;vertical-align:baseline;cursor:default;user-select:all;line-height:1.4;';
 272    if (pic) {
 273      const img = document.createElement('img');
 274      img.src = pic;
 275      img.style.cssText = 'width:16px;height:16px;border-radius:50%;object-fit:cover;';
 276      s.appendChild(img);
 277    }
 278    s.appendChild(document.createTextNode(name || ('@' + npub.slice(0, 12) + '…')));
 279    return s;
 280  }
 281  
 282  export function InsertMentionChip(elId, charCount, npub, name, pic) {
 283    const el = _elements.get(elId);
 284    if (!el) return;
 285    if (_isCE(el)) {
 286      el.focus();
 287      const sel = window.getSelection();
 288      if (!sel.rangeCount) return;
 289      for (let i = 0; i < charCount; i++) sel.modify('extend', 'backward', 'character');
 290      const range = sel.getRangeAt(0);
 291      range.deleteContents();
 292      const chip = _ceChip(npub, name, pic);
 293      range.insertNode(chip);
 294      const space = document.createTextNode(' ');
 295      if (chip.nextSibling) chip.parentNode.insertBefore(space, chip.nextSibling);
 296      else chip.parentNode.appendChild(space);
 297      range.setStartAfter(space);
 298      range.collapse(true);
 299      sel.removeAllRanges();
 300      sel.addRange(range);
 301      el.dispatchEvent(new Event('input', { bubbles: true }));
 302    } else {
 303      const val = el.value || '';
 304      const cur = el.selectionStart || 0;
 305      const mention = 'nostr:' + npub + ' ';
 306      const start = cur - charCount;
 307      el.value = val.substring(0, start) + mention + val.substring(cur);
 308      const nc = start + mention.length;
 309      el.selectionStart = nc;
 310      el.selectionEnd = nc;
 311    }
 312  }
 313  
 314  // Properties and attributes.
 315  export function SetAttribute(elId, name, value) {
 316    const el = _elements.get(elId);
 317    if (!el || !el.setAttribute) return;
 318    if (name === 'contenteditable' && (value === 'true' || value === 'plaintext-only')) {
 319      el.setAttribute(name, value);
 320      _ceSetup(el);
 321      return;
 322    }
 323    if (name === 'placeholder' && _isCE(el)) {
 324      el.setAttribute('data-placeholder', value);
 325      return;
 326    }
 327    el.setAttribute(name, value);
 328  }
 329  
 330  export function RemoveAttribute(elId, name) {
 331    const el = _elements.get(elId);
 332    if (el && el.removeAttribute) el.removeAttribute(name);
 333  }
 334  
 335  export function GetAttribute(elId, name) {
 336    const el = _elements.get(elId);
 337    return (el && el.getAttribute) ? (el.getAttribute(name) || '') : '';
 338  }
 339  
 340  export function SetTextContent(elId, text) {
 341    const el = _elements.get(elId);
 342    if (el) el.textContent = text;
 343  }
 344  
 345  export function SetInnerHTML(elId, html) {
 346    const el = _elements.get(elId);
 347    if (!el) return;
 348    if (!html) {
 349      if (el.replaceChildren) el.replaceChildren();
 350      else while (el.firstChild) el.removeChild(el.firstChild);
 351      return;
 352    }
 353    const tpl = document.createElement('template');
 354    const prop = 'inner' + 'HTML';
 355    tpl[prop] = html;
 356    if (el.replaceChildren) el.replaceChildren(tpl.content);
 357    else {
 358      while (el.firstChild) el.removeChild(el.firstChild);
 359      el.appendChild(tpl.content);
 360    }
 361  }
 362  
 363  export function SetStyle(elId, prop, value) {
 364    const el = _elements.get(elId);
 365    if (el && el.style) el.style[prop] = value;
 366  }
 367  
 368  export function Focus(elId) {
 369    const el = _elements.get(elId);
 370    if (el && el.focus) el.focus();
 371  }
 372  
 373  export function SetProperty(elId, prop, value) {
 374    const el = _elements.get(elId);
 375    if (!el) return;
 376    if (_isCE(el)) {
 377      if (prop === 'value') { _ceSetValue(el, value); return; }
 378      if (prop === 'selectionStart' || prop === 'selectionEnd') { _ceSetSelOff(el, parseInt(value) || 0); return; }
 379    }
 380    el[prop] = value;
 381  }
 382  
 383  export function GetProperty(elId, prop) {
 384    const el = _elements.get(elId);
 385    if (!el) return '';
 386    if (_isCE(el)) {
 387      if (prop === 'value') return _ceGetValue(el);
 388      if (prop === 'selectionStart' || prop === 'selectionEnd') return String(_ceGetSelOff(el));
 389    }
 390    return String(el[prop] ?? '');
 391  }
 392  
 393  export function BoundingClientLeft(elId) {
 394    const el = _elements.get(elId);
 395    if (!el || !el.getBoundingClientRect) return 0;
 396    return el.getBoundingClientRect().left | 0;
 397  }
 398  
 399  export function GetBoundingRect(elId, fn) {
 400    const el = _elements.get(elId);
 401    if (!el || !el.getBoundingClientRect) { fn(0, 0, 0, 0, 0, 0); return; }
 402    const r = el.getBoundingClientRect();
 403    fn(Math.round(r.left), Math.round(r.top), Math.round(r.right), Math.round(r.bottom), Math.round(r.width), Math.round(r.height));
 404  }
 405  
 406  export function GetViewportHeight() {
 407    return window.innerHeight | 0;
 408  }
 409  
 410  export function GetViewportWidth() {
 411    return window.innerWidth | 0;
 412  }
 413  
 414  export function AddClass(elId, cls) {
 415    const el = _elements.get(elId);
 416    if (el && el.classList) el.classList.add(cls);
 417  }
 418  
 419  export function RemoveClass(elId, cls) {
 420    const el = _elements.get(elId);
 421    if (el && el.classList) el.classList.remove(cls);
 422  }
 423  
 424  // Events.
 425  export function AddEventListener(elId, event, callbackId) {
 426    const el = _elements.get(elId);
 427    const cb = _callbacks.get(callbackId);
 428    if (el && cb) {
 429      el.addEventListener(event, cb);
 430      let set = _elemCallbacks.get(elId);
 431      if (!set) { set = new Set(); _elemCallbacks.set(elId, set); }
 432      set.add(callbackId);
 433      _cbEventInfo.set(callbackId, { el, event, fn: cb });
 434    }
 435  }
 436  
 437  // AddEnterKeyListener fires callback when Enter is pressed on the element.
 438  export function AddEnterKeyListener(elId, callbackId) {
 439    const el = _elements.get(elId);
 440    const cb = _callbacks.get(callbackId);
 441    if (el && cb) {
 442      const wrapper = function(e) { if (e.key === 'Enter') { e.preventDefault(); cb(); } };
 443      el.addEventListener('keydown', wrapper);
 444      let set = _elemCallbacks.get(elId);
 445      if (!set) { set = new Set(); _elemCallbacks.set(elId, set); }
 446      set.add(callbackId);
 447      _cbEventInfo.set(callbackId, { el, event: 'keydown', fn: wrapper });
 448    }
 449  }
 450  
 451  // OnKeydown registers a keydown handler that calls fn(key, prevent).
 452  // fn receives the key name as a string. To preventDefault, fn should call
 453  // the prevent callback.
 454  export function OnKeydown(elId, fn) {
 455    const el = _elements.get(elId);
 456    if (!el) return;
 457    el.addEventListener('keydown', function(e) {
 458      let stopped = false;
 459      fn(e.key, function() { stopped = true; });
 460      if (stopped) { e.preventDefault(); e.stopPropagation(); }
 461    });
 462  }
 463  
 464  // Like AddEventListener but only fires when e.target === e.currentTarget.
 465  // Useful for backdrop click-to-close without catching child clicks.
 466  export function AddSelfEventListener(elId, event, callbackId) {
 467    const el = _elements.get(elId);
 468    const cb = _callbacks.get(callbackId);
 469    if (el && cb) {
 470      const wrapper = function(e) { if (e.target === e.currentTarget) cb(); };
 471      el.addEventListener(event, wrapper);
 472      let set = _elemCallbacks.get(elId);
 473      if (!set) { set = new Set(); _elemCallbacks.set(elId, set); }
 474      set.add(callbackId);
 475      _cbEventInfo.set(callbackId, { el, event, fn: wrapper });
 476    }
 477  }
 478  
 479  export function RemoveEventListener(elId, event, callbackId) {
 480    const el = _elements.get(elId);
 481    const cb = _callbacks.get(callbackId);
 482    if (el && cb) el.removeEventListener(event, cb);
 483  }
 484  
 485  export function ObserveResize(elId, callbackId) {
 486    const el = _elements.get(elId);
 487    const cb = _callbacks.get(callbackId);
 488    if (!el || !cb) return;
 489    const old = _resizeObservers.get(elId);
 490    if (old) old.disconnect();
 491    const ro = new ResizeObserver(() => cb());
 492    ro.observe(el);
 493    _resizeObservers.set(elId, ro);
 494  }
 495  
 496  export function UnobserveResize(elId) {
 497    const ro = _resizeObservers.get(elId);
 498    if (ro) { ro.disconnect(); _resizeObservers.delete(elId); }
 499  }
 500  
 501  export function getElement(id) { return _elements.get(id); }
 502  
 503  // Register a Go function as a JS callback. Returns callback ID.
 504  export function RegisterCallback(fn) {
 505    const id = _nextCb++;
 506    _callbacks.set(id, fn);
 507    return id;
 508  }
 509  
 510  // Counts for the instrumentation hooks the wasm host exposes as
 511  // window.__moxie_element_count / window.__moxie_callback_count, used by the
 512  // memory tests. Tracked maps are the same ones the release paths drain.
 513  export function ElementCount() { return _elements.size; }
 514  export function CallbackCount() { return _callbacks.size; }
 515  
 516  export function ReleaseCallback(id) {
 517    _callbacks.delete(id);
 518  }
 519  
 520  // Scheduling.
 521  export function RequestAnimationFrame(fn) {
 522    if (typeof window !== 'undefined') {
 523      window.requestAnimationFrame(fn);
 524    } else {
 525      setTimeout(fn, 16);
 526    }
 527  }
 528  
 529  export function SetTimeout(fn, ms) {
 530    return setTimeout(fn, ms);
 531  }
 532  
 533  export function SetInterval(fn, ms) {
 534    return setInterval(fn, ms);
 535  }
 536  
 537  export function ClearInterval(id) {
 538    clearInterval(id);
 539  }
 540  
 541  export function ClearTimeout(id) {
 542    clearTimeout(id);
 543  }
 544  
 545  function _releaseHandle(id, el, released) {
 546    if (el) {
 547      _nodeToId.delete(el);
 548      if (el.tagName === 'IMG' || el.tagName === 'VIDEO') {
 549        const src = el.src;
 550        if (src && src.startsWith('blob:')) URL.revokeObjectURL(src);
 551      }
 552    }
 553    const ro = _resizeObservers.get(id);
 554    if (ro) { ro.disconnect(); _resizeObservers.delete(id); }
 555    _elements.delete(id);
 556    const cbs = _elemCallbacks.get(id);
 557    if (cbs) {
 558      cbs.forEach(cbId => {
 559        const info = _cbEventInfo.get(cbId);
 560        if (info) {
 561          try { info.el.removeEventListener(info.event, info.fn); } catch(_) {}
 562          _cbEventInfo.delete(cbId);
 563        }
 564        _callbacks.delete(cbId);
 565        if (released) released.push(cbId);
 566      });
 567      _elemCallbacks.delete(id);
 568    }
 569  }
 570  
 571  // Cleanup: release element handle.
 572  export function ReleaseElement(id) {
 573    const released = _onCallbacksReleased ? [] : null;
 574    _releaseHandle(id, _elements.get(id), released);
 575    if (released && released.length > 0) _onCallbacksReleased(released);
 576  }
 577  
 578  // Bulk release: drop all handles in the ids array.
 579  export function ReleaseAll(ids) {
 580    const released = _onCallbacksReleased ? [] : null;
 581    for (let i = 0; i < ids.length; i++) _releaseHandle(ids[i], _elements.get(ids[i]), released);
 582    if (released && released.length > 0) _onCallbacksReleased(released);
 583  }
 584  
 585  // ReleaseChildren releases handles, blob URLs, and callbacks for all DOM descendants.
 586  export function ReleaseChildren(elId) {
 587    const root = _elements.get(elId);
 588    if (!root) return;
 589    const released = _onCallbacksReleased ? [] : null;
 590    const stack = [];
 591    const ch = root.children;
 592    for (let i = 0; i < ch.length; i++) stack.push(ch[i]);
 593    while (stack.length > 0) {
 594      const node = stack.pop();
 595      const sub = node.children;
 596      for (let i = 0; i < sub.length; i++) stack.push(sub[i]);
 597      const id = _nodeToId.get(node);
 598      if (id !== undefined) _releaseHandle(id, node, released);
 599    }
 600    if (released && released.length > 0) _onCallbacksReleased(released);
 601  }
 602  
 603  // Fetch a URL as text, call fn with result.
 604  export function FetchText(url, fn) {
 605    fetch(url).then(r => r.text()).then(t => { if (fn) fn(t); }).catch(() => { if (fn) fn(''); });
 606  }
 607  
 608  // Navigation.
 609  export function NextSibling(elId) {
 610    const el = _elements.get(elId);
 611    return (el && el.nextSibling) ? _trackEl(el.nextSibling) : 0;
 612  }
 613  
 614  // Console / browser.
 615  export function ConsoleLog(msg) {
 616    console.log(msg);
 617  }
 618  
 619  export function PrefersDark() {
 620    if (typeof window !== 'undefined' && window.matchMedia) {
 621      return window.matchMedia('(prefers-color-scheme: dark)').matches;
 622    }
 623    return false;
 624  }
 625  
 626  export function Confirm(msg) {
 627    if (typeof window !== 'undefined') return window.confirm(msg);
 628    return false;
 629  }
 630  
 631  // Service Worker communication — with direct WebSocket fallback for insecure contexts.
 632  let _swMessageHandler = null;
 633  let _swQueue = [];
 634  const _hasSW = typeof navigator !== 'undefined' && 'serviceWorker' in navigator &&
 635    (typeof self !== 'undefined' && self.isSecureContext);
 636  
 637  // ========================================================================
 638  // Direct WebSocket relay fallback (active when SW unavailable)
 639  // Handles PROXY, REQ, CLOSE, EVENT messages using NIP-01 WebSocket protocol.
 640  // ========================================================================
 641  
 642  let _wsConns = {};       // url -> WebSocket
 643  let _wsSubs = {};        // subID -> { filter: string, relays: Set<url> }
 644  let _wsSeen = {};        // subID -> { eventID: true }
 645  let _wsEoseCount = {};   // subID -> count of relays that sent EOSE
 646  let _wsWriteRelays = []; // relay URLs for EVENT publishing
 647  
 648  function _diag(msg) {
 649    console.log(msg);
 650  }
 651  let _wsPendingPub = {};  // url -> [json strings] queued while CONNECTING
 652  
 653  function _wsSend(handler, msg) {
 654    if (handler) handler(msg);
 655  }
 656  
 657  // Count an EOSE (or CLOSED) for a sub. Fire EOSE to the app on the FIRST
 658  // relay response — matches the SW behaviour and prevents auth-requiring
 659  // relays from blocking the threshold.
 660  let _wsEoseFired = {};
 661  function _wsCountEose(sid) {
 662    if (!_wsEoseCount[sid]) _wsEoseCount[sid] = 0;
 663    _wsEoseCount[sid]++;
 664    if (!_wsEoseFired[sid]) {
 665      _wsEoseFired[sid] = true;
 666      _wsSend(_swMessageHandler, '["EOSE",' + JSON.stringify(sid) + ']');
 667    }
 668  }
 669  
 670  function _wsEnsure(url) {
 671    if (_wsConns[url]) return _wsConns[url];
 672    var ws;
 673    try { ws = new WebSocket(url); } catch(e) { return null; }
 674    _wsConns[url] = ws;
 675    ws.onopen = function() {
 676      _diag('WS OPEN ' + url);
 677      // Replay active subs for this relay.
 678      for (var sid in _wsSubs) {
 679        var sub = _wsSubs[sid];
 680        if (sub.relays.has(url)) {
 681          ws.send(JSON.stringify(["REQ", sid, JSON.parse(sub.filter)]));
 682        }
 683      }
 684      // Flush queued publishes.
 685      var pending = _wsPendingPub[url];
 686      if (pending) {
 687        _diag('FLUSH ' + pending.length + ' queued to ' + url);
 688        delete _wsPendingPub[url];
 689        for (var p = 0; p < pending.length; p++) ws.send(pending[p]);
 690      }
 691    };
 692    ws.onmessage = function(e) {
 693      var msg;
 694      try { msg = JSON.parse(e.data); } catch(ex) { return; }
 695      if (!Array.isArray(msg)) return;
 696      if (msg[0] === "EVENT" && msg.length >= 3) {
 697        var sid = msg[1], ev = msg[2];
 698        if (!_wsSeen[sid]) _wsSeen[sid] = {};
 699        if (ev.id && _wsSeen[sid][ev.id]) return;
 700        if (ev.id) {
 701          _wsSeen[sid][ev.id] = true;
 702          if (!_wsSeen[sid]._n) _wsSeen[sid]._n = 0;
 703          if (++_wsSeen[sid]._n > 5000) {
 704            var keys = Object.keys(_wsSeen[sid]);
 705            var keep = {}; var n = 0;
 706            for (var k = Math.floor(keys.length / 2); k < keys.length; k++) {
 707              if (keys[k] !== '_n') { keep[keys[k]] = true; n++; }
 708            }
 709            keep._n = n;
 710            _wsSeen[sid] = keep;
 711          }
 712        }
 713        _wsSend(_swMessageHandler, '["EVENT",' + JSON.stringify(sid) + ',' + JSON.stringify(ev) + ']');
 714        if (ev.id) _wsSend(_swMessageHandler, '["SEEN_ON",' + JSON.stringify(ev.id) + ',' + JSON.stringify(url) + ']');
 715      } else if (msg[0] === "EOSE" && msg.length >= 2) {
 716        _wsCountEose(msg[1]);
 717      } else if (msg[0] === "CLOSED" && msg.length >= 2) {
 718        _diag('CLOSED ' + url + ' sub=' + msg[1] + ' reason=' + (msg[2] || ''));
 719        _wsCountEose(msg[1]);
 720      } else if (msg[0] === "OK" && msg.length >= 3) {
 721        var okId = msg[1], okSuccess = msg[2], okMsg = msg[3] || '';
 722        if (okSuccess) { _diag('OK ' + url + ' id=' + (okId && okId.substring(0,8))); }
 723        else { _diag('REJECTED ' + url + ' ' + (okId && okId.substring(0,8)) + ': ' + okMsg); }
 724      } else if (msg[0] === "NOTICE") {
 725        _diag('NOTICE ' + url + ': ' + msg[1]);
 726      }
 727    };
 728    ws.onclose = function() {
 729      _diag('WS CLOSE ' + url);
 730      delete _wsConns[url];
 731      // Trigger EOSE for subs that included this relay (connection died before EOSE).
 732      for (var sid in _wsSubs) {
 733        if (_wsSubs[sid].relays.has(url)) _wsCountEose(sid);
 734      }
 735    };
 736    ws.onerror = function() { ws.close(); };
 737    return ws;
 738  }
 739  
 740  function _wsCloseSub(sid) {
 741    var sub = _wsSubs[sid];
 742    if (!sub) return;
 743    sub.relays.forEach(function(url) {
 744      var ws = _wsConns[url];
 745      if (ws && ws.readyState === WebSocket.OPEN) {
 746        try { ws.send(JSON.stringify(["CLOSE", sid])); } catch(e) {}
 747      }
 748    });
 749    delete _wsSubs[sid];
 750    delete _wsSeen[sid];
 751    delete _wsEoseCount[sid];
 752    delete _wsEoseFired[sid];
 753  }
 754  
 755  function _wsHandleMsg(raw) {
 756    var arr;
 757    try { arr = JSON.parse(raw); } catch(e) { return; }
 758    if (!Array.isArray(arr)) return;
 759    var cmd = arr[0];
 760    if (cmd === "SET_PUBKEY") {
 761      // no-op for direct WS
 762    } else if (cmd === "SET_WRITE_RELAYS") {
 763      _wsWriteRelays = arr[1] || [];
 764      for (var i = 0; i < _wsWriteRelays.length; i++) _wsEnsure(_wsWriteRelays[i]);
 765    } else if (cmd === "PROXY") {
 766      var sid = arr[1], filter = arr[2], relays = arr[3] || [];
 767      _diag('PROXY ' + sid + ' relays=' + relays.length);
 768      _wsCloseSub(sid);
 769      _wsSubs[sid] = { filter: JSON.stringify(filter), relays: new Set(relays) };
 770      _wsSeen[sid] = {};
 771      _wsEoseCount[sid] = 0;
 772      for (var j = 0; j < relays.length; j++) {
 773        var url = relays[j];
 774        var ws = _wsConns[url];
 775        // Drop dead connections (CLOSING/CLOSED) so _wsEnsure creates a fresh one.
 776        if (ws && ws.readyState > 1) {
 777          _diag('PROXY ' + sid + ' drop dead ws ' + url + ' state=' + ws.readyState);
 778          delete _wsConns[url];
 779          ws = null;
 780        }
 781        ws = _wsEnsure(url);
 782        if (ws && ws.readyState === WebSocket.OPEN) {
 783          _diag('PROXY ' + sid + ' REQ ' + url);
 784          ws.send(JSON.stringify(["REQ", sid, filter]));
 785        } else {
 786          _diag('PROXY ' + sid + ' DEFER ' + url + ' state=' + (ws ? ws.readyState : 'null'));
 787        }
 788      }
 789    } else if (cmd === "REQ") {
 790      var sid2 = arr[1], filter2 = arr[2];
 791      var url = _wsWriteRelays[0];
 792      if (url) {
 793        _wsCloseSub(sid2);
 794        _wsSubs[sid2] = { filter: JSON.stringify(filter2), relays: new Set([url]) };
 795        _wsSeen[sid2] = {};
 796        _wsEoseCount[sid2] = 0;
 797        var ws2 = _wsEnsure(url);
 798        if (ws2 && ws2.readyState === WebSocket.OPEN) {
 799          ws2.send(JSON.stringify(["REQ", sid2, filter2]));
 800        }
 801      }
 802    } else if (cmd === "CLOSE") {
 803      _wsCloseSub(arr[1]);
 804    } else if (cmd === "PUBLISH_TO") {
 805      var ev2 = arr[1], relays2 = arr[2] || [];
 806      _diag('PUBLISH_TO kind=' + (ev2 && ev2.kind) + ' id=' + (ev2 && ev2.id && ev2.id.substring(0,8)) + ' relays=' + relays2.length);
 807      var data2 = JSON.stringify(["EVENT", ev2]);
 808      for (var m = 0; m < relays2.length; m++) {
 809        var ws4 = _wsEnsure(relays2[m]);
 810        if (ws4 && ws4.readyState === WebSocket.OPEN) { _diag('SEND ' + relays2[m]); ws4.send(data2); }
 811        else if (ws4) { _diag('QUEUE ' + relays2[m] + ' state=' + ws4.readyState); if (!_wsPendingPub[relays2[m]]) _wsPendingPub[relays2[m]] = []; _wsPendingPub[relays2[m]].push(data2); }
 812        else { _diag('FAIL no WS ' + relays2[m]); }
 813      }
 814    } else if (cmd === "EVENT") {
 815      _diag('EVENT kind=' + (arr[1] && arr[1].kind) + ' writeRelays=' + _wsWriteRelays.length);
 816      var ev = arr[1], data = JSON.stringify(["EVENT", ev]);
 817      for (var k = 0; k < _wsWriteRelays.length; k++) {
 818        var ws3 = _wsEnsure(_wsWriteRelays[k]);
 819        if (ws3 && ws3.readyState === WebSocket.OPEN) { _diag('SEND ' + _wsWriteRelays[k]); ws3.send(data); }
 820        else if (ws3) { var u3 = _wsWriteRelays[k]; _diag('QUEUE ' + u3 + ' state=' + ws3.readyState); if (!_wsPendingPub[u3]) _wsPendingPub[u3] = []; _wsPendingPub[u3].push(data); }
 821        else { _diag('FAIL no WS ' + _wsWriteRelays[k]); }
 822      }
 823    } else if (cmd === "CLEAR_KEY") {
 824      for (var sid3 in _wsSubs) _wsCloseSub(sid3);
 825      for (var u in _wsConns) { try { _wsConns[u].close(); } catch(e) {} }
 826      _wsConns = {};
 827      _wsWriteRelays = [];
 828      _wsPendingPub = {};
 829    }
 830  }
 831  
 832  // ========================================================================
 833  
 834  // All PostToSW messages, in order. Used to replay to a new SW on controllerchange.
 835  var _swSent = [];
 836  
 837  export function PostToSW(msg) {
 838    if (!_hasSW) {
 839      _wsHandleMsg('' + msg);
 840      return;
 841    }
 842    var s = '' + msg;
 843    if (_swSent.length > 500) _swSent = _swSent.slice(-100);
 844    _swSent.push(s);
 845    if (navigator.serviceWorker.controller) {
 846      navigator.serviceWorker.controller.postMessage(s);
 847    } else {
 848      _swQueue.push(s);
 849    }
 850  }
 851  
 852  function _sendToSW() {
 853    if (!navigator.serviceWorker.controller) return;
 854    // Prefer queued messages (never sent). If queue is empty, replay all sent
 855    // messages (they went to an old SW that was replaced).
 856    var msgs;
 857    if (_swQueue.length > 0) {
 858      msgs = _swQueue;
 859      _swQueue = [];
 860    } else if (_swSent.length > 0) {
 861      msgs = _swSent.slice();
 862      _swSent = [];
 863    } else {
 864      return;
 865    }
 866    for (var i = 0; i < msgs.length; i++) {
 867      navigator.serviceWorker.controller.postMessage(msgs[i]);
 868    }
 869  }
 870  
 871  if (_hasSW) {
 872    navigator.serviceWorker.addEventListener('controllerchange', _sendToSW);
 873    navigator.serviceWorker.ready.then(function(reg) {
 874      if (!navigator.serviceWorker.controller && reg.active) {
 875        reg.active.postMessage('CLAIM');
 876      }
 877      setTimeout(_sendToSW, 100);
 878    });
 879  }
 880  
 881  let _swMessageListener = null;
 882  
 883  export function OnSWMessage(fn) {
 884    _swMessageHandler = fn;
 885    if (_hasSW) {
 886      if (_swMessageListener) {
 887        navigator.serviceWorker.removeEventListener('message', _swMessageListener);
 888      }
 889      _swMessageListener = function(e) {
 890        var d = e.data;
 891        if (typeof d !== 'string') {
 892          // Backward compat: old SW may send Slice objects via structured clone.
 893          if (d && d.$array && typeof d.$length === 'number') {
 894            var buf = new Uint8Array(d.$length);
 895            var off = d.$offset || 0;
 896            for (var i = 0; i < d.$length; i++) buf[i] = d.$array[off + i];
 897            d = new TextDecoder().decode(buf);
 898          } else {
 899            return; // Not a string and not a Slice — skip.
 900          }
 901        }
 902        if (_swMessageHandler) _swMessageHandler(d);
 903      };
 904      navigator.serviceWorker.addEventListener('message', _swMessageListener);
 905    }
 906  }
 907  
 908  export function ReadClipboard(fn) {
 909    if (navigator.clipboard && navigator.clipboard.readText) {
 910      navigator.clipboard.readText().then(function(t) { fn(t || ''); }).catch(function() {
 911        var r = prompt('Paste nsec:');
 912        fn(r || '');
 913      });
 914    } else {
 915      var r = prompt('Paste nsec:');
 916      fn(r || '');
 917    }
 918  }
 919  
 920  export function WriteClipboard(text, fn) {
 921    const s = '' + text;
 922    if (navigator.clipboard && navigator.clipboard.writeText) {
 923      navigator.clipboard.writeText(s).then(function() { fn(true); }).catch(function() { fn(false); });
 924      return;
 925    }
 926    try {
 927      const ta = document.createElement('textarea');
 928      ta.value = s;
 929      ta.style.position = 'fixed';
 930      ta.style.opacity = '0';
 931      document.body.appendChild(ta);
 932      ta.focus();
 933      ta.select();
 934      const ok = document.execCommand('copy');
 935      document.body.removeChild(ta);
 936      fn(!!ok);
 937    } catch (e) {
 938      fn(false);
 939    }
 940  }
 941  
 942  // WritePrimarySelection writes text to the X11 primary selection (middle-click
 943  // paste buffer). Creates an off-screen textarea, selects it, and relies on the
 944  // browser propagating the selection to the X server. No-op on non-X11 platforms.
 945  export function WritePrimarySelection(text) {
 946    const s = '' + text;
 947    try {
 948      const ta = document.createElement('textarea');
 949      ta.value = s;
 950      ta.style.position = 'fixed';
 951      ta.style.top = '-9999px';
 952      ta.style.left = '-9999px';
 953      ta.style.opacity = '0';
 954      document.body.appendChild(ta);
 955      ta.focus();
 956      ta.select();
 957      document.execCommand('copy');
 958      document.body.removeChild(ta);
 959    } catch (_) {}
 960  }
 961  
 962  // CopyImageToClipboard copies an image element's pixel data to the clipboard
 963  // as image/png via ClipboardItem. Requires a secure context (HTTPS or localhost).
 964  // fn is called with true on success, false on failure or if API unavailable.
 965  export function CopyImageToClipboard(elId, fn) {
 966    const img = _elements.get(elId);
 967    if (!img || !navigator.clipboard || !window.ClipboardItem) {
 968      fn(false);
 969      return;
 970    }
 971    try {
 972      const canvas = document.createElement('canvas');
 973      canvas.width = img.naturalWidth || img.width || 1;
 974      canvas.height = img.naturalHeight || img.height || 1;
 975      const ctx = canvas.getContext('2d');
 976      ctx.drawImage(img, 0, 0);
 977      canvas.toBlob(function(blob) {
 978        if (!blob) { fn(false); return; }
 979        navigator.clipboard.write([new ClipboardItem({'image/png': blob})])
 980          .then(function() { fn(true); })
 981          .catch(function() { fn(false); });
 982      }, 'image/png');
 983    } catch (_) {
 984      fn(false);
 985    }
 986  }
 987  
 988  // Fetch relay NIP-11 info.
 989  export function FetchRelayInfo(url, fn) {
 990    fetch(url, { headers: { 'Accept': 'application/nostr+json' } })
 991      .then(function(r) { return r.text(); })
 992      .then(function(t) { if (fn) fn(t); })
 993      .catch(function() { if (fn) fn(''); });
 994  }
 995  
 996  // History / location.
 997  export function PushState(path) {
 998    if (typeof history !== 'undefined') history.pushState(null, '', path);
 999  }
1000  
1001  export function ReplaceState(path) {
1002    if (typeof history !== 'undefined') history.replaceState(null, '', path);
1003  }
1004  
1005  export function Back() {
1006    if (typeof history !== 'undefined') history.back();
1007  }
1008  
1009  export function LocationReload() {
1010    if (typeof location !== 'undefined') location.reload();
1011  }
1012  
1013  export function LocationAssign(url) {
1014    if (typeof location !== 'undefined') location.assign(url);
1015  }
1016  
1017  export function HardRefresh() {
1018    var p = [];
1019    if (typeof caches !== 'undefined') {
1020      p.push(caches.keys().then(function(keys) {
1021        return Promise.all(keys.map(function(k) { return caches.delete(k); }));
1022      }));
1023    }
1024    if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
1025      p.push(navigator.serviceWorker.getRegistrations().then(function(regs) {
1026        regs.forEach(function(r) { r.unregister(); });
1027      }));
1028    }
1029    Promise.all(p).then(function() { location.reload(); });
1030  }
1031  
1032  export function ClearStoragePrefix(prefix) {
1033    if (typeof localStorage === 'undefined') return;
1034    var keys = [];
1035    for (var i = 0; i < localStorage.length; i++) {
1036      var k = localStorage.key(i);
1037      if (k && k.indexOf(prefix) === 0) keys.push(k);
1038    }
1039    for (var j = 0; j < keys.length; j++) localStorage.removeItem(keys[j]);
1040  }
1041  
1042  export function TimezoneOffsetSeconds() {
1043    // getTimezoneOffset() returns minutes, positive west of UTC.
1044    // We return seconds to ADD to UTC to get local time.
1045    return -(new Date().getTimezoneOffset()) * 60;
1046  }
1047  
1048  export function GetPath() {
1049    if (typeof location !== 'undefined') return location.pathname;
1050    return '/';
1051  }
1052  
1053  export function GetHash() {
1054    if (typeof location !== 'undefined') return location.hash;
1055    return '';
1056  }
1057  
1058  export function Hostname() {
1059    if (typeof location !== 'undefined') return location.hostname;
1060    return '';
1061  }
1062  
1063  export function Port() {
1064    if (typeof location !== 'undefined') return location.port;
1065    return '';
1066  }
1067  
1068  export function UserAgent() {
1069    if (typeof navigator !== 'undefined') return navigator.userAgent;
1070    return '';
1071  }
1072  
1073  let _popStateListener = null;
1074  
1075  export function OnPopState(fn) {
1076    if (typeof window !== 'undefined') {
1077      if (_popStateListener) {
1078        window.removeEventListener('popstate', _popStateListener);
1079      }
1080      _popStateListener = function() {
1081        if (fn) fn(location.pathname);
1082      };
1083      window.addEventListener('popstate', _popStateListener);
1084    }
1085  }
1086  
1087  export function InterceptInternalLinks(fn) {
1088    if (typeof document === 'undefined') return;
1089    document.addEventListener('click', function(e) {
1090      let el = e.target;
1091      while (el && el.tagName !== 'A') el = el.parentElement;
1092      if (!el) return;
1093      const href = el.getAttribute('href');
1094      if (!href || href[0] !== '/') return;
1095      e.preventDefault();
1096      history.pushState(null, '', href);
1097      if (fn) fn(href);
1098    });
1099  }
1100  
1101  // DownloadText triggers a file download with the given text content.
1102  export function DownloadText(filename, content, mimeType) {
1103    const blob = new Blob([content], { type: mimeType || 'text/plain' });
1104    const url = URL.createObjectURL(blob);
1105    const a = document.createElement('a');
1106    a.href = url;
1107    a.download = filename;
1108    document.body.appendChild(a);
1109    a.click();
1110    setTimeout(function() { a.remove(); URL.revokeObjectURL(url); }, 1000);
1111  }
1112  
1113  // PickFileText opens a file picker and reads the selected file as text.
1114  export function PickFileText(accept, fn) {
1115    const input = document.createElement('input');
1116    input.type = 'file';
1117    if (accept) input.accept = accept;
1118    input.style.display = 'none';
1119    input.addEventListener('change', function() {
1120      if (!input.files || !input.files[0]) { fn(''); return; }
1121      const reader = new FileReader();
1122      reader.onload = function() { fn(reader.result || ''); };
1123      reader.onerror = function() { fn(''); };
1124      reader.readAsText(input.files[0]);
1125    });
1126    document.body.appendChild(input);
1127    input.click();
1128    // Clean up after a delay (even if user cancels).
1129    setTimeout(function() { input.remove(); }, 60000);
1130  }
1131  
1132  // NowSeconds returns current Unix time in seconds.
1133  export function NowSeconds() {
1134    return BigInt(Math.floor(Date.now() / 1000));
1135  }
1136  
1137  // OnPullRefresh registers a handler that fires when the user pulls to refresh.
1138  // Handles both mouse wheel (accumulated up-scroll at top) and touch gestures.
1139  export function OnPullRefresh(elId, indicatorId, fn) {
1140    const el = _elements.get(elId);
1141    const ind = _elements.get(indicatorId);
1142    if (!el) return;
1143  
1144    var pullAccum = 0;
1145    var pullTimer = 0;
1146    var threshold = 200;
1147    var resetMs = 400;
1148  
1149    el.addEventListener('wheel', function(e) {
1150      if (el.scrollTop > 0 || e.deltaY >= 0) {
1151        pullAccum = 0;
1152        if (ind) ind.style.display = 'none';
1153        return;
1154      }
1155      pullAccum += Math.abs(e.deltaY);
1156      clearTimeout(pullTimer);
1157      pullTimer = setTimeout(function() {
1158        pullAccum = 0;
1159        if (ind) ind.style.display = 'none';
1160      }, resetMs);
1161      if (ind && pullAccum > 40) ind.style.display = 'flex';
1162      if (pullAccum >= threshold) {
1163        pullAccum = 0;
1164        fn();
1165      }
1166    }, { passive: true });
1167  
1168    var touchStartY = 0;
1169    el.addEventListener('touchstart', function(e) {
1170      if (el.scrollTop === 0 && e.touches.length === 1) {
1171        touchStartY = e.touches[0].clientY;
1172      }
1173    }, { passive: true });
1174    el.addEventListener('touchmove', function(e) {
1175      if (el.scrollTop > 0 || touchStartY === 0) return;
1176      var dy = e.touches[0].clientY - touchStartY;
1177      if (dy > 20 && ind) ind.style.display = 'flex';
1178      if (dy > 80) {
1179        touchStartY = 0;
1180        fn();
1181      }
1182    }, { passive: true });
1183    el.addEventListener('touchend', function() {
1184      touchStartY = 0;
1185      if (pullAccum < threshold && ind) ind.style.display = 'none';
1186    }, { passive: true });
1187  }
1188  
1189  // PickFileBase64: open file picker, read as base64, call fn(base64, mime).
1190  export function PickFileBase64(accept, fn) {
1191    const input = document.createElement('input');
1192    input.type = 'file';
1193    if (accept) input.accept = accept;
1194    input.style.display = 'none';
1195    input.addEventListener('change', function() {
1196      if (!input.files || !input.files[0]) { fn('', ''); return; }
1197      const file = input.files[0];
1198      const mime = file.type || 'application/octet-stream';
1199      const reader = new FileReader();
1200      reader.onload = function() {
1201        const b64 = reader.result.split(',')[1] || '';
1202        fn(b64, mime);
1203      };
1204      reader.onerror = function() { fn('', ''); };
1205      reader.readAsDataURL(file);
1206      input.remove();
1207    });
1208    document.body.appendChild(input);
1209    input.click();
1210    setTimeout(function() { input.remove(); }, 120000);
1211  }
1212  
1213  // OnPasteImage: listen for paste events with image data on an element.
1214  export function OnPasteImage(elId, fn) {
1215    const el = _elements.get(elId);
1216    if (!el) return;
1217    el.addEventListener('paste', function(e) {
1218      const items = e.clipboardData && e.clipboardData.items;
1219      if (!items) return;
1220      for (let i = 0; i < items.length; i++) {
1221        if (items[i].type.indexOf('image/') === 0) {
1222          e.preventDefault();
1223          const mime = items[i].type;
1224          const blob = items[i].getAsFile();
1225          if (!blob) continue;
1226          const reader = new FileReader();
1227          reader.onload = function() {
1228            const b64 = reader.result.split(',')[1] || '';
1229            fn(b64, mime);
1230          };
1231          reader.readAsDataURL(blob);
1232          return;
1233        }
1234      }
1235    });
1236  }
1237  
1238  // OnDropImage: listen for drop events with image files on an element.
1239  export function OnDropImage(elId, fn) {
1240    const el = _elements.get(elId);
1241    if (!el) return;
1242    el.addEventListener('dragenter', function(e) { e.preventDefault(); e.stopPropagation(); });
1243    el.addEventListener('dragover', function(e) { e.preventDefault(); e.stopPropagation(); });
1244    el.addEventListener('drop', function(e) {
1245      e.preventDefault();
1246      e.stopPropagation();
1247      const files = e.dataTransfer && e.dataTransfer.files;
1248      if (!files || files.length === 0) return;
1249      for (let i = 0; i < files.length; i++) {
1250        const f = files[i];
1251        const mime = f.type || 'application/octet-stream';
1252        if (mime.indexOf('image/') !== 0) continue;
1253        const reader = new FileReader();
1254        reader.onload = function() {
1255          const b64 = reader.result.split(',')[1] || '';
1256          fn(b64, mime);
1257        };
1258        reader.readAsDataURL(f);
1259      }
1260    });
1261  }
1262  
1263  // FetchPutBlobBase64: PUT binary data (from base64) to a URL with auth header.
1264  export function FetchPutBlobBase64(url, base64data, contentType, authHeader, fn) {
1265    try {
1266      const raw = atob(base64data);
1267      const arr = new Uint8Array(raw.length);
1268      for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
1269      const headers = { 'Content-Type': contentType || 'application/octet-stream' };
1270      if (authHeader) headers['Authorization'] = authHeader;
1271      fetch(url, { method: 'PUT', headers: headers, body: arr })
1272        .then(function(r) { return r.text(); })
1273        .then(function(t) { if (fn) fn(t); })
1274        .catch(function() { if (fn) fn(''); });
1275    } catch(e) {
1276      if (fn) fn('');
1277    }
1278  }
1279  
1280  // Get raw element (for advanced use within JS runtime only).
1281  export function getRawElement(id) {
1282    return _elements.get(id);
1283  }
1284