1// ermite.js: the one thing hx-live cannot do is a global key *sequence* state machine
2// ("g" then "h"), nor ignore keystrokes aimed at inputs before any element is focused.
3// It only translates keys into DOM events named "k-<name>" on <body>; every reaction
4// (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live.
5(() => {
6 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
7 s: 's', t: 't', c: 'c', r: 'r', p: 'p', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' };
8 const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', p: 'gp', g: 'top' };
9 let pending = 0;
10 const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
11 addEventListener('keydown', (e) => {
12 if (e.ctrlKey || e.metaKey || e.altKey) return;
13 const typing = e.target.closest('input, textarea, select, [contenteditable]');
14 if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); }
15 if (typing) return;
16 if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
17 if (pending) {
18 const name = goto[e.key];
19 pending = 0;
20 if (name) { e.preventDefault(); fire(name); }
21 return;
22 }
23 if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; }
24 const name = single[e.key];
25 if (!name) return;
26 if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault();
27 fire(name);
28 });
29 // In the app shell the page does not scroll, <main> does: give it focus so arrows,
30 // PageUp/PageDown and space scroll the content without a click first.
31 if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
32})();
33
34// ermiteLive applies the fragments of the event stream (/-/events, see
35// internal/adapters/web/events.go). A fragment is a top-level element with an
36// id: it replaces the element of the page that has the same id. A fragment
37// marked data-gone removes it; a row of a repository the home page does not
38// list yet is added at the top of the list. A gap, which says the page may
39// have missed events or that what it shows changed, reloads the page.
40window.ermiteLive = (() => {
41 let reloading = false;
42 return {
43 swap(html) {
44 const t = document.createElement('template');
45 t.innerHTML = html;
46 for (const el of t.content.children) {
47 const old = el.id && document.getElementById(el.id);
48 if (el.hasAttribute('data-gone')) { old?.remove(); continue; }
49 if (!old) {
50 const rows = document.getElementById('repo-rows');
51 if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
52 continue;
53 }
54 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
55 if (old.hidden && old.tagName === 'TR') el.hidden = true;
56 old.replaceWith(el);
57 window.htmx?.process(el);
58 }
59 },
60 reload() {
61 if (reloading) return;
62 reloading = true;
63 setTimeout(() => location.reload(), 300);
64 },
65 };
66})();