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', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' };
8 const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', m: 'gm', g: 'top' };
9 let pending = 0;
10 const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
11 addEventListener('keydown', (e) => {
12 const typing = e.target.closest('input, textarea, select, [contenteditable]');
13 // ctrl k / cmd k opens the palette; in a field only cmd k, so that the
14 // field keeps ctrl k (kill to end of line on macOS).
15 if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return fire('cmd'); }
16 if (e.ctrlKey || e.metaKey || e.altKey) return;
17 if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); }
18 if (typing) return;
19 // An open dialog takes only the keys that move between dialogs: the page
20 // behind it does not react (scripted clicks would go through inert).
21 if (document.querySelector('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; }
22 if (e.key === 'Enter' && e.target.closest('a, button, summary')) return;
23 if (pending) {
24 const name = goto[e.key];
25 pending = 0;
26 if (name) { e.preventDefault(); fire(name); }
27 return;
28 }
29 if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; }
30 const name = single[e.key];
31 if (!name) return;
32 if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault();
33 fire(name);
34 });
35 // In the app shell the page does not scroll, <main> does: give it focus so arrows,
36 // PageUp/PageDown and space scroll the content without a click first.
37 if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
38})();
39
40// ermiteLive applies the fragments of the event stream (/-/events, see
41// internal/adapters/web/events.go). A fragment is a top-level element with an
42// id: it replaces the element of the page that has the same id. A fragment
43// marked data-gone removes it; a row of a repository the home page does not
44// list yet is added at the top of the list. A gap, which says the page may
45// have missed events or that what it shows changed, reloads the page.
46window.ermiteLive = (() => {
47 let reloading = false;
48 return {
49 swap(html) {
50 const t = document.createElement('template');
51 t.innerHTML = html;
52 for (const el of t.content.children) {
53 const old = el.id && document.getElementById(el.id);
54 if (el.hasAttribute('data-gone')) { old?.remove(); continue; }
55 if (!old) {
56 const rows = document.getElementById('repo-rows');
57 if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
58 continue;
59 }
60 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
61 if (old.hidden && old.tagName === 'TR') el.hidden = true;
62 old.replaceWith(el);
63 window.htmx?.process(el);
64 }
65 },
66 reload() {
67 if (reloading) return;
68 reloading = true;
69 setTimeout(() => location.reload(), 300);
70 },
71 };
72})();