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  const _elements = new Map();
   7  let _nextId = 1; // Safe up to 2^53 in JS. No wrap needed.
   8  const _callbacks = new Map();
   9  let _nextCb = 1;
  10  
  11  // Bootstrap: map handle 0 to document.body (available after DOMContentLoaded).
  12  function ensureBody() {
  13    if (!_elements.has(0) && typeof document !== 'undefined' && document.body) {
  14      _elements.set(0, document.body);
  15    }
  16  }
  17  
  18  // Element creation and lookup.
  19  export function CreateElement(tag) {
  20    const el = document.createElement(tag);
  21    const id = _nextId++;
  22    _elements.set(id, el);
  23    return id;
  24  }
  25  
  26  export function CreateTextNode(text) {
  27    const node = document.createTextNode(text);
  28    const id = _nextId++;
  29    _elements.set(id, node);
  30    return id;
  31  }
  32  
  33  export function GetElementById(id) {
  34    const el = document.getElementById(id);
  35    if (!el) return 0;
  36    const handle = _nextId++;
  37    _elements.set(handle, el);
  38    return handle;
  39  }
  40  
  41  export function QuerySelector(sel) {
  42    const el = document.querySelector(sel);
  43    if (!el) return 0;
  44    const handle = _nextId++;
  45    _elements.set(handle, el);
  46    return handle;
  47  }
  48  
  49  export function Body() {
  50    ensureBody();
  51    return 0;
  52  }
  53  
  54  // Tree manipulation.
  55  export function AppendChild(parentId, childId) {
  56    const parent = _elements.get(parentId);
  57    const child = _elements.get(childId);
  58    if (parent && child) parent.appendChild(child);
  59  }
  60  
  61  export function RemoveChild(parentId, childId) {
  62    const parent = _elements.get(parentId);
  63    const child = _elements.get(childId);
  64    if (parent && child) parent.removeChild(child);
  65  }
  66  
  67  export function FirstChild(parentId) {
  68    const parent = _elements.get(parentId);
  69    if (parent && parent.firstChild) {
  70      const id = _nextId++;
  71      _elements.set(id, parent.firstChild);
  72      return id;
  73    }
  74    return 0;
  75  }
  76  
  77  export function FirstElementChild(parentId) {
  78    const parent = _elements.get(parentId);
  79    if (parent && parent.firstElementChild) {
  80      const id = _nextId++;
  81      _elements.set(id, parent.firstElementChild);
  82      return id;
  83    }
  84    return 0;
  85  }
  86  
  87  export function InsertBefore(parentId, newId, refId) {
  88    const parent = _elements.get(parentId);
  89    const newEl = _elements.get(newId);
  90    const ref = refId >= 0 ? _elements.get(refId) : null;
  91    if (parent && newEl) parent.insertBefore(newEl, ref);
  92  }
  93  
  94  export function ReplaceChild(parentId, newId, oldId) {
  95    const parent = _elements.get(parentId);
  96    const newEl = _elements.get(newId);
  97    const oldEl = _elements.get(oldId);
  98    if (parent && newEl && oldEl) parent.replaceChild(newEl, oldEl);
  99  }
 100  
 101  // Properties and attributes.
 102  export function SetAttribute(elId, name, value) {
 103    const el = _elements.get(elId);
 104    if (el && el.setAttribute) el.setAttribute(name, value);
 105  }
 106  
 107  export function RemoveAttribute(elId, name) {
 108    const el = _elements.get(elId);
 109    if (el && el.removeAttribute) el.removeAttribute(name);
 110  }
 111  
 112  export function SetTextContent(elId, text) {
 113    const el = _elements.get(elId);
 114    if (el) el.textContent = text;
 115  }
 116  
 117  export function SetInnerHTML(elId, html) {
 118    const el = _elements.get(elId);
 119    if (el && 'innerHTML' in el) el.innerHTML = html;
 120  }
 121  
 122  export function SetStyle(elId, prop, value) {
 123    const el = _elements.get(elId);
 124    if (el && el.style) el.style[prop] = value;
 125  }
 126  
 127  export function Focus(elId) {
 128    const el = _elements.get(elId);
 129    if (el && el.focus) el.focus();
 130  }
 131  
 132  export function SetProperty(elId, prop, value) {
 133    const el = _elements.get(elId);
 134    if (el) el[prop] = value;
 135  }
 136  
 137  export function GetProperty(elId, prop) {
 138    const el = _elements.get(elId);
 139    if (el) return String(el[prop] ?? '');
 140    return '';
 141  }
 142  
 143  export function AddClass(elId, cls) {
 144    const el = _elements.get(elId);
 145    if (el && el.classList) el.classList.add(cls);
 146  }
 147  
 148  export function RemoveClass(elId, cls) {
 149    const el = _elements.get(elId);
 150    if (el && el.classList) el.classList.remove(cls);
 151  }
 152  
 153  // Events.
 154  export function AddEventListener(elId, event, callbackId) {
 155    const el = _elements.get(elId);
 156    const cb = _callbacks.get(callbackId);
 157    if (el && cb) el.addEventListener(event, cb);
 158  }
 159  
 160  // AddEnterKeyListener fires callback when Enter is pressed on the element.
 161  export function AddEnterKeyListener(elId, callbackId) {
 162    const el = _elements.get(elId);
 163    const cb = _callbacks.get(callbackId);
 164    if (el && cb) el.addEventListener('keydown', function(e) {
 165      if (e.key === 'Enter') { e.preventDefault(); cb(); }
 166    });
 167  }
 168  
 169  // Like AddEventListener but only fires when e.target === e.currentTarget.
 170  // Useful for backdrop click-to-close without catching child clicks.
 171  export function AddSelfEventListener(elId, event, callbackId) {
 172    const el = _elements.get(elId);
 173    const cb = _callbacks.get(callbackId);
 174    if (el && cb) el.addEventListener(event, function(e) {
 175      if (e.target === e.currentTarget) cb();
 176    });
 177  }
 178  
 179  export function RemoveEventListener(elId, event, callbackId) {
 180    const el = _elements.get(elId);
 181    const cb = _callbacks.get(callbackId);
 182    if (el && cb) el.removeEventListener(event, cb);
 183  }
 184  
 185  // Register a Go function as a JS callback. Returns callback ID.
 186  export function RegisterCallback(fn) {
 187    const id = _nextCb++;
 188    _callbacks.set(id, fn);
 189    return id;
 190  }
 191  
 192  export function ReleaseCallback(id) {
 193    _callbacks.delete(id);
 194  }
 195  
 196  // Scheduling.
 197  export function RequestAnimationFrame(fn) {
 198    if (typeof window !== 'undefined') {
 199      window.requestAnimationFrame(fn);
 200    } else {
 201      setTimeout(fn, 16);
 202    }
 203  }
 204  
 205  export function SetTimeout(fn, ms) {
 206    return setTimeout(fn, ms);
 207  }
 208  
 209  export function SetInterval(fn, ms) {
 210    return setInterval(fn, ms);
 211  }
 212  
 213  export function ClearInterval(id) {
 214    clearInterval(id);
 215  }
 216  
 217  export function ClearTimeout(id) {
 218    clearTimeout(id);
 219  }
 220  
 221  // Cleanup: release element handle.
 222  export function ReleaseElement(id) {
 223    _elements.delete(id);
 224  }
 225  
 226  // Fetch a URL as text, call fn with result.
 227  export function FetchText(url, fn) {
 228    fetch(url).then(r => r.text()).then(t => { if (fn) fn(t); });
 229  }
 230  
 231  // Navigation.
 232  export function NextSibling(elId) {
 233    const el = _elements.get(elId);
 234    if (el && el.nextSibling) {
 235      const id = _nextId++;
 236      _elements.set(id, el.nextSibling);
 237      return id;
 238    }
 239    return 0;
 240  }
 241  
 242  // Console / browser.
 243  export function ConsoleLog(msg) {
 244    console.log(msg);
 245  }
 246  
 247  export function PrefersDark() {
 248    if (typeof window !== 'undefined' && window.matchMedia) {
 249      return window.matchMedia('(prefers-color-scheme: dark)').matches;
 250    }
 251    return false;
 252  }
 253  
 254  export function Confirm(msg) {
 255    if (typeof window !== 'undefined') return window.confirm(msg);
 256    return false;
 257  }
 258  
 259  // Service Worker communication — with direct WebSocket fallback for insecure contexts.
 260  let _swMessageHandler = null;
 261  let _swQueue = [];
 262  const _hasSW = typeof navigator !== 'undefined' && 'serviceWorker' in navigator &&
 263    (typeof self !== 'undefined' && self.isSecureContext);
 264  
 265  // ========================================================================
 266  // Direct WebSocket relay fallback (active when SW unavailable)
 267  // Handles PROXY, REQ, CLOSE, EVENT messages using NIP-01 WebSocket protocol.
 268  // ========================================================================
 269  
 270  let _wsConns = {};       // url -> WebSocket
 271  let _wsSubs = {};        // subID -> { filter: string, relays: Set<url> }
 272  let _wsSeen = {};        // subID -> { eventID: true }
 273  let _wsEoseCount = {};   // subID -> count of relays that sent EOSE
 274  let _wsWriteRelays = []; // relay URLs for EVENT publishing
 275  
 276  function _wsSend(handler, msg) {
 277    if (handler) handler(msg);
 278  }
 279  
 280  function _wsEnsure(url) {
 281    if (_wsConns[url]) return _wsConns[url];
 282    var ws;
 283    try { ws = new WebSocket(url); } catch(e) { return null; }
 284    _wsConns[url] = ws;
 285    ws.onopen = function() {
 286      // Replay active subs for this relay.
 287      for (var sid in _wsSubs) {
 288        var sub = _wsSubs[sid];
 289        if (sub.relays.has(url)) {
 290          ws.send(JSON.stringify(["REQ", sid, JSON.parse(sub.filter)]));
 291        }
 292      }
 293    };
 294    ws.onmessage = function(e) {
 295      var msg;
 296      try { msg = JSON.parse(e.data); } catch(ex) { return; }
 297      if (!Array.isArray(msg)) return;
 298      if (msg[0] === "EVENT" && msg.length >= 3) {
 299        var sid = msg[1], ev = msg[2];
 300        if (!_wsSeen[sid]) _wsSeen[sid] = {};
 301        if (ev.id && _wsSeen[sid][ev.id]) return;
 302        if (ev.id) _wsSeen[sid][ev.id] = true;
 303        _wsSend(_swMessageHandler, '["EVENT",' + JSON.stringify(sid) + ',' + JSON.stringify(ev) + ']');
 304        if (ev.id) _wsSend(_swMessageHandler, '["SEEN_ON",' + JSON.stringify(ev.id) + ',' + JSON.stringify(url) + ']');
 305      } else if (msg[0] === "EOSE" && msg.length >= 2) {
 306        var sid2 = msg[1];
 307        if (!_wsEoseCount[sid2]) _wsEoseCount[sid2] = 0;
 308        _wsEoseCount[sid2]++;
 309        var sub2 = _wsSubs[sid2];
 310        if (sub2 && _wsEoseCount[sid2] >= sub2.relays.size) {
 311          _wsSend(_swMessageHandler, '["EOSE",' + JSON.stringify(sid2) + ']');
 312        }
 313      }
 314    };
 315    ws.onclose = function() {
 316      delete _wsConns[url];
 317      // Trigger EOSE for subs that included this relay (connection died before EOSE).
 318      for (var sid in _wsSubs) {
 319        var sub = _wsSubs[sid];
 320        if (sub.relays.has(url)) {
 321          if (!_wsEoseCount[sid]) _wsEoseCount[sid] = 0;
 322          _wsEoseCount[sid]++;
 323          if (_wsEoseCount[sid] >= sub.relays.size) {
 324            _wsSend(_swMessageHandler, '["EOSE",' + JSON.stringify(sid) + ']');
 325          }
 326        }
 327      }
 328    };
 329    ws.onerror = function() { ws.close(); };
 330    return ws;
 331  }
 332  
 333  function _wsCloseSub(sid) {
 334    var sub = _wsSubs[sid];
 335    if (!sub) return;
 336    sub.relays.forEach(function(url) {
 337      var ws = _wsConns[url];
 338      if (ws && ws.readyState === WebSocket.OPEN) {
 339        try { ws.send(JSON.stringify(["CLOSE", sid])); } catch(e) {}
 340      }
 341    });
 342    delete _wsSubs[sid];
 343    delete _wsSeen[sid];
 344    delete _wsEoseCount[sid];
 345  }
 346  
 347  function _wsHandleMsg(raw) {
 348    var arr;
 349    try { arr = JSON.parse(raw); } catch(e) { return; }
 350    if (!Array.isArray(arr)) return;
 351    var cmd = arr[0];
 352    if (cmd === "SET_PUBKEY") {
 353      // no-op for direct WS
 354    } else if (cmd === "SET_WRITE_RELAYS") {
 355      _wsWriteRelays = arr[1] || [];
 356      for (var i = 0; i < _wsWriteRelays.length; i++) _wsEnsure(_wsWriteRelays[i]);
 357    } else if (cmd === "PROXY") {
 358      var sid = arr[1], filter = arr[2], relays = arr[3] || [];
 359      _wsCloseSub(sid);
 360      _wsSubs[sid] = { filter: JSON.stringify(filter), relays: new Set(relays) };
 361      _wsSeen[sid] = {};
 362      _wsEoseCount[sid] = 0;
 363      for (var j = 0; j < relays.length; j++) {
 364        var ws = _wsEnsure(relays[j]);
 365        if (ws && ws.readyState === WebSocket.OPEN) {
 366          ws.send(JSON.stringify(["REQ", sid, filter]));
 367        }
 368      }
 369    } else if (cmd === "REQ") {
 370      var sid2 = arr[1], filter2 = arr[2];
 371      var url = _wsWriteRelays[0];
 372      if (url) {
 373        _wsCloseSub(sid2);
 374        _wsSubs[sid2] = { filter: JSON.stringify(filter2), relays: new Set([url]) };
 375        _wsSeen[sid2] = {};
 376        _wsEoseCount[sid2] = 0;
 377        var ws2 = _wsEnsure(url);
 378        if (ws2 && ws2.readyState === WebSocket.OPEN) {
 379          ws2.send(JSON.stringify(["REQ", sid2, filter2]));
 380        }
 381      }
 382    } else if (cmd === "CLOSE") {
 383      _wsCloseSub(arr[1]);
 384    } else if (cmd === "EVENT") {
 385      var ev = arr[1], data = JSON.stringify(["EVENT", ev]);
 386      for (var k = 0; k < _wsWriteRelays.length; k++) {
 387        var ws3 = _wsEnsure(_wsWriteRelays[k]);
 388        if (ws3 && ws3.readyState === WebSocket.OPEN) ws3.send(data);
 389      }
 390    } else if (cmd === "CLEAR_KEY") {
 391      for (var sid3 in _wsSubs) _wsCloseSub(sid3);
 392      for (var u in _wsConns) { try { _wsConns[u].close(); } catch(e) {} }
 393      _wsConns = {};
 394      _wsWriteRelays = [];
 395    }
 396  }
 397  
 398  // ========================================================================
 399  
 400  // All PostToSW messages, in order. Used to replay to a new SW on controllerchange.
 401  var _swSent = [];
 402  
 403  export function PostToSW(msg) {
 404    if (!_hasSW) {
 405      _wsHandleMsg(msg);
 406      return;
 407    }
 408    var s = '' + msg;
 409    _swSent.push(s);
 410    if (navigator.serviceWorker.controller) {
 411      navigator.serviceWorker.controller.postMessage(s);
 412    } else {
 413      _swQueue.push(s);
 414    }
 415  }
 416  
 417  function _sendToSW() {
 418    if (!navigator.serviceWorker.controller) return;
 419    // Prefer queued messages (never sent). If queue is empty, replay all sent
 420    // messages (they went to an old SW that was replaced).
 421    var msgs;
 422    if (_swQueue.length > 0) {
 423      msgs = _swQueue;
 424      _swQueue = [];
 425    } else if (_swSent.length > 0) {
 426      msgs = _swSent;
 427    } else {
 428      return;
 429    }
 430    for (var i = 0; i < msgs.length; i++) {
 431      navigator.serviceWorker.controller.postMessage(msgs[i]);
 432    }
 433  }
 434  
 435  if (_hasSW) {
 436    navigator.serviceWorker.addEventListener('controllerchange', _sendToSW);
 437    navigator.serviceWorker.ready.then(function(reg) {
 438      if (!navigator.serviceWorker.controller && reg.active) {
 439        reg.active.postMessage('CLAIM');
 440      }
 441      setTimeout(_sendToSW, 100);
 442    });
 443  }
 444  
 445  export function OnSWMessage(fn) {
 446    _swMessageHandler = fn;
 447    if (_hasSW) {
 448      navigator.serviceWorker.addEventListener('message', function(e) {
 449        var d = e.data;
 450        if (typeof d !== 'string') {
 451          // Backward compat: old SW may send Slice objects via structured clone.
 452          if (d && d.$array && typeof d.$length === 'number') {
 453            var buf = new Uint8Array(d.$length);
 454            var off = d.$offset || 0;
 455            for (var i = 0; i < d.$length; i++) buf[i] = d.$array[off + i];
 456            d = new TextDecoder().decode(buf);
 457          } else {
 458            return; // Not a string and not a Slice — skip.
 459          }
 460        }
 461        if (_swMessageHandler) _swMessageHandler(d);
 462      });
 463    }
 464  }
 465  
 466  export function ReadClipboard(fn) {
 467    if (navigator.clipboard && navigator.clipboard.readText) {
 468      navigator.clipboard.readText().then(function(t) { fn(t || ''); }).catch(function() {
 469        var r = prompt('Paste nsec:');
 470        fn(r || '');
 471      });
 472    } else {
 473      var r = prompt('Paste nsec:');
 474      fn(r || '');
 475    }
 476  }
 477  
 478  // IndexedDB (simple key-value).
 479  let _idb = null;
 480  const _idbName = 'smesh-kv';
 481  const _idbStores = ['profiles', 'events', 'cache'];
 482  
 483  function _openIDB() {
 484    return new Promise(function(resolve, reject) {
 485      if (_idb) { resolve(_idb); return; }
 486      const req = indexedDB.open(_idbName, 1);
 487      req.onupgradeneeded = function(e) {
 488        const db = e.target.result;
 489        for (const s of _idbStores) {
 490          if (!db.objectStoreNames.contains(s)) db.createObjectStore(s);
 491        }
 492      };
 493      req.onsuccess = function(e) { _idb = e.target.result; resolve(_idb); };
 494      req.onerror = function() { reject(req.error); };
 495    });
 496  }
 497  
 498  export function IDBGet(store, key, fn) {
 499    _openIDB().then(function(db) {
 500      const tx = db.transaction(store, 'readonly');
 501      const req = tx.objectStore(store).get(key);
 502      req.onsuccess = function() { fn(req.result ?? ''); };
 503      req.onerror = function() { fn(''); };
 504    }).catch(function() { fn(''); });
 505  }
 506  
 507  export function IDBPut(store, key, value) {
 508    _openIDB().then(function(db) {
 509      const tx = db.transaction(store, 'readwrite');
 510      tx.objectStore(store).put(value, key);
 511    });
 512  }
 513  
 514  export function IDBGetAll(store, fn, done) {
 515    _openIDB().then(function(db) {
 516      const tx = db.transaction(store, 'readonly');
 517      const req = tx.objectStore(store).openCursor();
 518      req.onsuccess = function(e) {
 519        const cursor = e.target.result;
 520        if (cursor) {
 521          fn(String(cursor.key), String(cursor.value));
 522          cursor.continue();
 523        } else {
 524          if (done) done();
 525        }
 526      };
 527      req.onerror = function() { if (done) done(); };
 528    }).catch(function() { if (done) done(); });
 529  }
 530  
 531  // Fetch relay NIP-11 info.
 532  export function FetchRelayInfo(url, fn) {
 533    fetch(url, { headers: { 'Accept': 'application/nostr+json' } })
 534      .then(function(r) { return r.text(); })
 535      .then(function(t) { if (fn) fn(t); })
 536      .catch(function() { if (fn) fn(''); });
 537  }
 538  
 539  // History / location.
 540  export function PushState(path) {
 541    if (typeof history !== 'undefined') history.pushState(null, '', path);
 542  }
 543  
 544  export function ReplaceState(path) {
 545    if (typeof history !== 'undefined') history.replaceState(null, '', path);
 546  }
 547  
 548  export function Back() {
 549    if (typeof history !== 'undefined') history.back();
 550  }
 551  
 552  export function LocationReload() {
 553    if (typeof location !== 'undefined') location.reload();
 554  }
 555  
 556  export function HardRefresh() {
 557    var p = [];
 558    if (typeof caches !== 'undefined') {
 559      p.push(caches.keys().then(function(keys) {
 560        return Promise.all(keys.map(function(k) { return caches.delete(k); }));
 561      }));
 562    }
 563    if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
 564      p.push(navigator.serviceWorker.getRegistrations().then(function(regs) {
 565        regs.forEach(function(r) { r.unregister(); });
 566      }));
 567    }
 568    Promise.all(p).then(function() { location.reload(); });
 569  }
 570  
 571  export function ClearStoragePrefix(prefix) {
 572    if (typeof localStorage === 'undefined') return;
 573    var keys = [];
 574    for (var i = 0; i < localStorage.length; i++) {
 575      var k = localStorage.key(i);
 576      if (k && k.indexOf(prefix) === 0) keys.push(k);
 577    }
 578    for (var j = 0; j < keys.length; j++) localStorage.removeItem(keys[j]);
 579  }
 580  
 581  export function TimezoneOffsetSeconds() {
 582    // getTimezoneOffset() returns minutes, positive west of UTC.
 583    // We return seconds to ADD to UTC to get local time.
 584    return -(new Date().getTimezoneOffset()) * 60;
 585  }
 586  
 587  export function GetPath() {
 588    if (typeof location !== 'undefined') return location.pathname;
 589    return '/';
 590  }
 591  
 592  export function Hostname() {
 593    if (typeof location !== 'undefined') return location.hostname;
 594    return '';
 595  }
 596  
 597  export function Port() {
 598    if (typeof location !== 'undefined') return location.port;
 599    return '';
 600  }
 601  
 602  export function UserAgent() {
 603    if (typeof navigator !== 'undefined') return navigator.userAgent;
 604    return '';
 605  }
 606  
 607  export function OnPopState(fn) {
 608    if (typeof window !== 'undefined') {
 609      window.addEventListener('popstate', function() {
 610        if (fn) fn(location.pathname);
 611      });
 612    }
 613  }
 614  
 615  // DownloadText triggers a file download with the given text content.
 616  export function DownloadText(filename, content, mimeType) {
 617    const blob = new Blob([content], { type: mimeType || 'text/plain' });
 618    const url = URL.createObjectURL(blob);
 619    const a = document.createElement('a');
 620    a.href = url;
 621    a.download = filename;
 622    document.body.appendChild(a);
 623    a.click();
 624    setTimeout(function() { a.remove(); URL.revokeObjectURL(url); }, 1000);
 625  }
 626  
 627  // PickFileText opens a file picker and reads the selected file as text.
 628  export function PickFileText(accept, fn) {
 629    const input = document.createElement('input');
 630    input.type = 'file';
 631    if (accept) input.accept = accept;
 632    input.style.display = 'none';
 633    input.addEventListener('change', function() {
 634      if (!input.files || !input.files[0]) { fn(''); return; }
 635      const reader = new FileReader();
 636      reader.onload = function() { fn(reader.result || ''); };
 637      reader.onerror = function() { fn(''); };
 638      reader.readAsText(input.files[0]);
 639    });
 640    document.body.appendChild(input);
 641    input.click();
 642    // Clean up after a delay (even if user cancels).
 643    setTimeout(function() { input.remove(); }, 60000);
 644  }
 645  
 646  // NowSeconds returns current Unix time in seconds.
 647  export function NowSeconds() {
 648    return BigInt(Math.floor(Date.now() / 1000));
 649  }
 650  
 651  // OnPullRefresh registers a handler that fires when the user pulls to refresh.
 652  // Handles both mouse wheel (accumulated up-scroll at top) and touch gestures.
 653  export function OnPullRefresh(elId, indicatorId, fn) {
 654    const el = _elements.get(elId);
 655    const ind = _elements.get(indicatorId);
 656    if (!el) return;
 657  
 658    var pullAccum = 0;
 659    var pullTimer = 0;
 660    var threshold = 200;
 661    var resetMs = 400;
 662  
 663    el.addEventListener('wheel', function(e) {
 664      if (el.scrollTop > 0 || e.deltaY >= 0) {
 665        pullAccum = 0;
 666        if (ind) ind.style.display = 'none';
 667        return;
 668      }
 669      pullAccum += Math.abs(e.deltaY);
 670      clearTimeout(pullTimer);
 671      pullTimer = setTimeout(function() {
 672        pullAccum = 0;
 673        if (ind) ind.style.display = 'none';
 674      }, resetMs);
 675      if (ind && pullAccum > 40) ind.style.display = 'flex';
 676      if (pullAccum >= threshold) {
 677        pullAccum = 0;
 678        fn();
 679      }
 680    }, { passive: true });
 681  
 682    var touchStartY = 0;
 683    el.addEventListener('touchstart', function(e) {
 684      if (el.scrollTop === 0 && e.touches.length === 1) {
 685        touchStartY = e.touches[0].clientY;
 686      }
 687    }, { passive: true });
 688    el.addEventListener('touchmove', function(e) {
 689      if (el.scrollTop > 0 || touchStartY === 0) return;
 690      var dy = e.touches[0].clientY - touchStartY;
 691      if (dy > 20 && ind) ind.style.display = 'flex';
 692      if (dy > 80) {
 693        touchStartY = 0;
 694        fn();
 695      }
 696    }, { passive: true });
 697    el.addEventListener('touchend', function() {
 698      touchStartY = 0;
 699      if (pullAccum < threshold && ind) ind.style.display = 'none';
 700    }, { passive: true });
 701  }
 702  
 703  // Get raw element (for advanced use within JS runtime only).
 704  export function getRawElement(id) {
 705    return _elements.get(id);
 706  }
 707