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 // Visible diagnostic overlay for mobile debugging (no console access).
277 var _diagEl = null;
278 function _diag(msg) {
279 console.log(msg);
280 if (!_diagEl) {
281 _diagEl = document.createElement('div');
282 _diagEl.style.cssText = 'position:fixed;bottom:0;left:0;right:0;max-height:40vh;overflow-y:auto;background:#111;color:#0f0;font:11px/1.4 monospace;padding:6px;z-index:99999;pointer-events:auto;';
283 document.body.appendChild(_diagEl);
284 }
285 var line = document.createElement('div');
286 line.textContent = msg;
287 _diagEl.appendChild(line);
288 _diagEl.scrollTop = _diagEl.scrollHeight;
289 }
290 let _wsPendingPub = {}; // url -> [json strings] queued while CONNECTING
291
292 function _wsSend(handler, msg) {
293 if (handler) handler(msg);
294 }
295
296 function _wsEnsure(url) {
297 if (_wsConns[url]) return _wsConns[url];
298 var ws;
299 try { ws = new WebSocket(url); } catch(e) { return null; }
300 _wsConns[url] = ws;
301 ws.onopen = function() {
302 _diag('WS OPEN ' + url);
303 // Replay active subs for this relay.
304 for (var sid in _wsSubs) {
305 var sub = _wsSubs[sid];
306 if (sub.relays.has(url)) {
307 ws.send(JSON.stringify(["REQ", sid, JSON.parse(sub.filter)]));
308 }
309 }
310 // Flush queued publishes.
311 var pending = _wsPendingPub[url];
312 if (pending) {
313 _diag('FLUSH ' + pending.length + ' queued to ' + url);
314 delete _wsPendingPub[url];
315 for (var p = 0; p < pending.length; p++) ws.send(pending[p]);
316 }
317 };
318 ws.onmessage = function(e) {
319 var msg;
320 try { msg = JSON.parse(e.data); } catch(ex) { return; }
321 if (!Array.isArray(msg)) return;
322 if (msg[0] === "EVENT" && msg.length >= 3) {
323 var sid = msg[1], ev = msg[2];
324 if (!_wsSeen[sid]) _wsSeen[sid] = {};
325 if (ev.id && _wsSeen[sid][ev.id]) return;
326 if (ev.id) _wsSeen[sid][ev.id] = true;
327 _wsSend(_swMessageHandler, '["EVENT",' + JSON.stringify(sid) + ',' + JSON.stringify(ev) + ']');
328 if (ev.id) _wsSend(_swMessageHandler, '["SEEN_ON",' + JSON.stringify(ev.id) + ',' + JSON.stringify(url) + ']');
329 } else if (msg[0] === "EOSE" && msg.length >= 2) {
330 var sid2 = msg[1];
331 if (!_wsEoseCount[sid2]) _wsEoseCount[sid2] = 0;
332 _wsEoseCount[sid2]++;
333 var sub2 = _wsSubs[sid2];
334 if (sub2 && _wsEoseCount[sid2] >= sub2.relays.size) {
335 _wsSend(_swMessageHandler, '["EOSE",' + JSON.stringify(sid2) + ']');
336 }
337 } else if (msg[0] === "OK" && msg.length >= 3) {
338 var okId = msg[1], okSuccess = msg[2], okMsg = msg[3] || '';
339 if (okSuccess) { _diag('OK ' + url + ' id=' + (okId && okId.substring(0,8))); }
340 else { _diag('REJECTED ' + url + ' ' + (okId && okId.substring(0,8)) + ': ' + okMsg); }
341 } else if (msg[0] === "NOTICE") {
342 _diag('NOTICE ' + url + ': ' + msg[1]);
343 }
344 };
345 ws.onclose = function() {
346 _diag('WS CLOSE ' + url);
347 delete _wsConns[url];
348 // Trigger EOSE for subs that included this relay (connection died before EOSE).
349 for (var sid in _wsSubs) {
350 var sub = _wsSubs[sid];
351 if (sub.relays.has(url)) {
352 if (!_wsEoseCount[sid]) _wsEoseCount[sid] = 0;
353 _wsEoseCount[sid]++;
354 if (_wsEoseCount[sid] >= sub.relays.size) {
355 _wsSend(_swMessageHandler, '["EOSE",' + JSON.stringify(sid) + ']');
356 }
357 }
358 }
359 };
360 ws.onerror = function() { ws.close(); };
361 return ws;
362 }
363
364 function _wsCloseSub(sid) {
365 var sub = _wsSubs[sid];
366 if (!sub) return;
367 sub.relays.forEach(function(url) {
368 var ws = _wsConns[url];
369 if (ws && ws.readyState === WebSocket.OPEN) {
370 try { ws.send(JSON.stringify(["CLOSE", sid])); } catch(e) {}
371 }
372 });
373 delete _wsSubs[sid];
374 delete _wsSeen[sid];
375 delete _wsEoseCount[sid];
376 }
377
378 function _wsHandleMsg(raw) {
379 var arr;
380 try { arr = JSON.parse(raw); } catch(e) { return; }
381 if (!Array.isArray(arr)) return;
382 var cmd = arr[0];
383 if (cmd === "SET_PUBKEY") {
384 // no-op for direct WS
385 } else if (cmd === "SET_WRITE_RELAYS") {
386 _wsWriteRelays = arr[1] || [];
387 for (var i = 0; i < _wsWriteRelays.length; i++) _wsEnsure(_wsWriteRelays[i]);
388 } else if (cmd === "PROXY") {
389 var sid = arr[1], filter = arr[2], relays = arr[3] || [];
390 _wsCloseSub(sid);
391 _wsSubs[sid] = { filter: JSON.stringify(filter), relays: new Set(relays) };
392 _wsSeen[sid] = {};
393 _wsEoseCount[sid] = 0;
394 for (var j = 0; j < relays.length; j++) {
395 var ws = _wsEnsure(relays[j]);
396 if (ws && ws.readyState === WebSocket.OPEN) {
397 ws.send(JSON.stringify(["REQ", sid, filter]));
398 }
399 }
400 } else if (cmd === "REQ") {
401 var sid2 = arr[1], filter2 = arr[2];
402 var url = _wsWriteRelays[0];
403 if (url) {
404 _wsCloseSub(sid2);
405 _wsSubs[sid2] = { filter: JSON.stringify(filter2), relays: new Set([url]) };
406 _wsSeen[sid2] = {};
407 _wsEoseCount[sid2] = 0;
408 var ws2 = _wsEnsure(url);
409 if (ws2 && ws2.readyState === WebSocket.OPEN) {
410 ws2.send(JSON.stringify(["REQ", sid2, filter2]));
411 }
412 }
413 } else if (cmd === "CLOSE") {
414 _wsCloseSub(arr[1]);
415 } else if (cmd === "PUBLISH_TO") {
416 var ev2 = arr[1], relays2 = arr[2] || [];
417 _diag('PUBLISH_TO kind=' + (ev2 && ev2.kind) + ' id=' + (ev2 && ev2.id && ev2.id.substring(0,8)) + ' relays=' + relays2.length);
418 var data2 = JSON.stringify(["EVENT", ev2]);
419 for (var m = 0; m < relays2.length; m++) {
420 var ws4 = _wsEnsure(relays2[m]);
421 if (ws4 && ws4.readyState === WebSocket.OPEN) { _diag('SEND ' + relays2[m]); ws4.send(data2); }
422 else if (ws4) { _diag('QUEUE ' + relays2[m] + ' state=' + ws4.readyState); if (!_wsPendingPub[relays2[m]]) _wsPendingPub[relays2[m]] = []; _wsPendingPub[relays2[m]].push(data2); }
423 else { _diag('FAIL no WS ' + relays2[m]); }
424 }
425 } else if (cmd === "EVENT") {
426 _diag('EVENT kind=' + (arr[1] && arr[1].kind) + ' writeRelays=' + _wsWriteRelays.length);
427 var ev = arr[1], data = JSON.stringify(["EVENT", ev]);
428 for (var k = 0; k < _wsWriteRelays.length; k++) {
429 var ws3 = _wsEnsure(_wsWriteRelays[k]);
430 if (ws3 && ws3.readyState === WebSocket.OPEN) { _diag('SEND ' + _wsWriteRelays[k]); ws3.send(data); }
431 else if (ws3) { var u3 = _wsWriteRelays[k]; _diag('QUEUE ' + u3 + ' state=' + ws3.readyState); if (!_wsPendingPub[u3]) _wsPendingPub[u3] = []; _wsPendingPub[u3].push(data); }
432 else { _diag('FAIL no WS ' + _wsWriteRelays[k]); }
433 }
434 } else if (cmd === "CLEAR_KEY") {
435 for (var sid3 in _wsSubs) _wsCloseSub(sid3);
436 for (var u in _wsConns) { try { _wsConns[u].close(); } catch(e) {} }
437 _wsConns = {};
438 _wsWriteRelays = [];
439 _wsPendingPub = {};
440 }
441 }
442
443 // ========================================================================
444
445 // All PostToSW messages, in order. Used to replay to a new SW on controllerchange.
446 var _swSent = [];
447
448 export function PostToSW(msg) {
449 if (!_hasSW) {
450 _wsHandleMsg('' + msg);
451 return;
452 }
453 var s = '' + msg;
454 _swSent.push(s);
455 if (navigator.serviceWorker.controller) {
456 navigator.serviceWorker.controller.postMessage(s);
457 } else {
458 _swQueue.push(s);
459 }
460 }
461
462 function _sendToSW() {
463 if (!navigator.serviceWorker.controller) return;
464 // Prefer queued messages (never sent). If queue is empty, replay all sent
465 // messages (they went to an old SW that was replaced).
466 var msgs;
467 if (_swQueue.length > 0) {
468 msgs = _swQueue;
469 _swQueue = [];
470 } else if (_swSent.length > 0) {
471 msgs = _swSent;
472 } else {
473 return;
474 }
475 for (var i = 0; i < msgs.length; i++) {
476 navigator.serviceWorker.controller.postMessage(msgs[i]);
477 }
478 }
479
480 if (_hasSW) {
481 navigator.serviceWorker.addEventListener('controllerchange', _sendToSW);
482 navigator.serviceWorker.ready.then(function(reg) {
483 if (!navigator.serviceWorker.controller && reg.active) {
484 reg.active.postMessage('CLAIM');
485 }
486 setTimeout(_sendToSW, 100);
487 });
488 }
489
490 export function OnSWMessage(fn) {
491 _swMessageHandler = fn;
492 if (_hasSW) {
493 navigator.serviceWorker.addEventListener('message', function(e) {
494 var d = e.data;
495 if (typeof d !== 'string') {
496 // Backward compat: old SW may send Slice objects via structured clone.
497 if (d && d.$array && typeof d.$length === 'number') {
498 var buf = new Uint8Array(d.$length);
499 var off = d.$offset || 0;
500 for (var i = 0; i < d.$length; i++) buf[i] = d.$array[off + i];
501 d = new TextDecoder().decode(buf);
502 } else {
503 return; // Not a string and not a Slice — skip.
504 }
505 }
506 if (_swMessageHandler) _swMessageHandler(d);
507 });
508 }
509 }
510
511 export function ReadClipboard(fn) {
512 if (navigator.clipboard && navigator.clipboard.readText) {
513 navigator.clipboard.readText().then(function(t) { fn(t || ''); }).catch(function() {
514 var r = prompt('Paste nsec:');
515 fn(r || '');
516 });
517 } else {
518 var r = prompt('Paste nsec:');
519 fn(r || '');
520 }
521 }
522
523 // IndexedDB (simple key-value).
524 let _idb = null;
525 const _idbName = 'smesh-kv';
526 const _idbStores = ['profiles', 'events', 'cache'];
527
528 function _openIDB() {
529 return new Promise(function(resolve, reject) {
530 if (_idb) { resolve(_idb); return; }
531 const req = indexedDB.open(_idbName, 1);
532 req.onupgradeneeded = function(e) {
533 const db = e.target.result;
534 for (const s of _idbStores) {
535 if (!db.objectStoreNames.contains(s)) db.createObjectStore(s);
536 }
537 };
538 req.onsuccess = function(e) { _idb = e.target.result; resolve(_idb); };
539 req.onerror = function() { reject(req.error); };
540 });
541 }
542
543 export function IDBGet(store, key, fn) {
544 _openIDB().then(function(db) {
545 const tx = db.transaction(store, 'readonly');
546 const req = tx.objectStore(store).get(key);
547 req.onsuccess = function() { fn(req.result ?? ''); };
548 req.onerror = function() { fn(''); };
549 }).catch(function() { fn(''); });
550 }
551
552 export function IDBPut(store, key, value) {
553 _openIDB().then(function(db) {
554 const tx = db.transaction(store, 'readwrite');
555 tx.objectStore(store).put(value, key);
556 });
557 }
558
559 export function IDBGetAll(store, fn, done) {
560 _openIDB().then(function(db) {
561 const tx = db.transaction(store, 'readonly');
562 const req = tx.objectStore(store).openCursor();
563 req.onsuccess = function(e) {
564 const cursor = e.target.result;
565 if (cursor) {
566 fn(String(cursor.key), String(cursor.value));
567 cursor.continue();
568 } else {
569 if (done) done();
570 }
571 };
572 req.onerror = function() { if (done) done(); };
573 }).catch(function() { if (done) done(); });
574 }
575
576 // Fetch relay NIP-11 info.
577 export function FetchRelayInfo(url, fn) {
578 fetch(url, { headers: { 'Accept': 'application/nostr+json' } })
579 .then(function(r) { return r.text(); })
580 .then(function(t) { if (fn) fn(t); })
581 .catch(function() { if (fn) fn(''); });
582 }
583
584 // History / location.
585 export function PushState(path) {
586 if (typeof history !== 'undefined') history.pushState(null, '', path);
587 }
588
589 export function ReplaceState(path) {
590 if (typeof history !== 'undefined') history.replaceState(null, '', path);
591 }
592
593 export function Back() {
594 if (typeof history !== 'undefined') history.back();
595 }
596
597 export function LocationReload() {
598 if (typeof location !== 'undefined') location.reload();
599 }
600
601 export function HardRefresh() {
602 var p = [];
603 if (typeof caches !== 'undefined') {
604 p.push(caches.keys().then(function(keys) {
605 return Promise.all(keys.map(function(k) { return caches.delete(k); }));
606 }));
607 }
608 if (typeof navigator !== 'undefined' && navigator.serviceWorker) {
609 p.push(navigator.serviceWorker.getRegistrations().then(function(regs) {
610 regs.forEach(function(r) { r.unregister(); });
611 }));
612 }
613 Promise.all(p).then(function() { location.reload(); });
614 }
615
616 export function ClearStoragePrefix(prefix) {
617 if (typeof localStorage === 'undefined') return;
618 var keys = [];
619 for (var i = 0; i < localStorage.length; i++) {
620 var k = localStorage.key(i);
621 if (k && k.indexOf(prefix) === 0) keys.push(k);
622 }
623 for (var j = 0; j < keys.length; j++) localStorage.removeItem(keys[j]);
624 }
625
626 export function TimezoneOffsetSeconds() {
627 // getTimezoneOffset() returns minutes, positive west of UTC.
628 // We return seconds to ADD to UTC to get local time.
629 return -(new Date().getTimezoneOffset()) * 60;
630 }
631
632 export function GetPath() {
633 if (typeof location !== 'undefined') return location.pathname;
634 return '/';
635 }
636
637 export function Hostname() {
638 if (typeof location !== 'undefined') return location.hostname;
639 return '';
640 }
641
642 export function Port() {
643 if (typeof location !== 'undefined') return location.port;
644 return '';
645 }
646
647 export function UserAgent() {
648 if (typeof navigator !== 'undefined') return navigator.userAgent;
649 return '';
650 }
651
652 export function OnPopState(fn) {
653 if (typeof window !== 'undefined') {
654 window.addEventListener('popstate', function() {
655 if (fn) fn(location.pathname);
656 });
657 }
658 }
659
660 // DownloadText triggers a file download with the given text content.
661 export function DownloadText(filename, content, mimeType) {
662 const blob = new Blob([content], { type: mimeType || 'text/plain' });
663 const url = URL.createObjectURL(blob);
664 const a = document.createElement('a');
665 a.href = url;
666 a.download = filename;
667 document.body.appendChild(a);
668 a.click();
669 setTimeout(function() { a.remove(); URL.revokeObjectURL(url); }, 1000);
670 }
671
672 // PickFileText opens a file picker and reads the selected file as text.
673 export function PickFileText(accept, fn) {
674 const input = document.createElement('input');
675 input.type = 'file';
676 if (accept) input.accept = accept;
677 input.style.display = 'none';
678 input.addEventListener('change', function() {
679 if (!input.files || !input.files[0]) { fn(''); return; }
680 const reader = new FileReader();
681 reader.onload = function() { fn(reader.result || ''); };
682 reader.onerror = function() { fn(''); };
683 reader.readAsText(input.files[0]);
684 });
685 document.body.appendChild(input);
686 input.click();
687 // Clean up after a delay (even if user cancels).
688 setTimeout(function() { input.remove(); }, 60000);
689 }
690
691 // NowSeconds returns current Unix time in seconds.
692 export function NowSeconds() {
693 return BigInt(Math.floor(Date.now() / 1000));
694 }
695
696 // OnPullRefresh registers a handler that fires when the user pulls to refresh.
697 // Handles both mouse wheel (accumulated up-scroll at top) and touch gestures.
698 export function OnPullRefresh(elId, indicatorId, fn) {
699 const el = _elements.get(elId);
700 const ind = _elements.get(indicatorId);
701 if (!el) return;
702
703 var pullAccum = 0;
704 var pullTimer = 0;
705 var threshold = 200;
706 var resetMs = 400;
707
708 el.addEventListener('wheel', function(e) {
709 if (el.scrollTop > 0 || e.deltaY >= 0) {
710 pullAccum = 0;
711 if (ind) ind.style.display = 'none';
712 return;
713 }
714 pullAccum += Math.abs(e.deltaY);
715 clearTimeout(pullTimer);
716 pullTimer = setTimeout(function() {
717 pullAccum = 0;
718 if (ind) ind.style.display = 'none';
719 }, resetMs);
720 if (ind && pullAccum > 40) ind.style.display = 'flex';
721 if (pullAccum >= threshold) {
722 pullAccum = 0;
723 fn();
724 }
725 }, { passive: true });
726
727 var touchStartY = 0;
728 el.addEventListener('touchstart', function(e) {
729 if (el.scrollTop === 0 && e.touches.length === 1) {
730 touchStartY = e.touches[0].clientY;
731 }
732 }, { passive: true });
733 el.addEventListener('touchmove', function(e) {
734 if (el.scrollTop > 0 || touchStartY === 0) return;
735 var dy = e.touches[0].clientY - touchStartY;
736 if (dy > 20 && ind) ind.style.display = 'flex';
737 if (dy > 80) {
738 touchStartY = 0;
739 fn();
740 }
741 }, { passive: true });
742 el.addEventListener('touchend', function() {
743 touchStartY = 0;
744 if (pullAccum < threshold && ind) ind.style.display = 'none';
745 }, { passive: true });
746 }
747
748 // Get raw element (for advanced use within JS runtime only).
749 export function getRawElement(id) {
750 return _elements.get(id);
751 }
752