| 1 | /* Client-side behaviour: theme toggle, sidebar filter, file finder, |
| 2 | copy buttons, line-range highlighting, wrap toggle, TOC scroll-spy. */ |
| 3 | (function () { |
| 4 | 'use strict'; |
| 5 | |
| 6 | const $ = (sel, root = document) => root.querySelector(sel); |
| 7 | const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); |
| 8 | |
| 9 | /* ---------- theme ---------- */ |
| 10 | const themeBtn = $('#theme-toggle'); |
| 11 | function applyThemeCss() { |
| 12 | const t = document.documentElement.dataset.theme; |
| 13 | const light = $('link[data-theme-css="light"]'); |
| 14 | const dark = $('link[data-theme-css="dark"]'); |
| 15 | if (!light || !dark) return; |
| 16 | if (t === 'light') { light.media = 'all'; dark.media = 'not all'; } |
| 17 | else if (t === 'dark') { light.media = 'not all'; dark.media = 'all'; } |
| 18 | else { light.media = '(prefers-color-scheme: light)'; dark.media = '(prefers-color-scheme: dark)'; } |
| 19 | } |
| 20 | applyThemeCss(); |
| 21 | if (themeBtn) { |
| 22 | themeBtn.addEventListener('click', () => { |
| 23 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; |
| 24 | const cur = document.documentElement.dataset.theme || (prefersDark ? 'dark' : 'light'); |
| 25 | const next = cur === 'dark' ? 'light' : 'dark'; |
| 26 | document.documentElement.dataset.theme = next; |
| 27 | try { localStorage.setItem('theme', next); } catch (e) { /* ignore */ } |
| 28 | applyThemeCss(); |
| 29 | document.dispatchEvent(new CustomEvent('themechange', { detail: next })); |
| 30 | }); |
| 31 | } |
| 32 | |
| 33 | /* ---------- sidebar filter ---------- */ |
| 34 | const filter = $('#tree-filter'); |
| 35 | const tree = $('#tree'); |
| 36 | if (filter && tree) { |
| 37 | const files = $$('.tree-file', tree); |
| 38 | const dirs = $$('.tree-dir', tree); |
| 39 | let openState = null; |
| 40 | filter.addEventListener('input', () => { |
| 41 | const q = filter.value.trim().toLowerCase(); |
| 42 | if (!q) { |
| 43 | files.forEach((li) => li.classList.remove('filtered-out')); |
| 44 | dirs.forEach((li) => li.classList.remove('filtered-out')); |
| 45 | if (openState) { dirs.forEach((li, i) => { const d = li.querySelector('details'); if (d) d.open = openState[i]; }); openState = null; } |
| 46 | return; |
| 47 | } |
| 48 | if (!openState) openState = dirs.map((li) => { const d = li.querySelector('details'); return d ? d.open : false; }); |
| 49 | files.forEach((li) => { |
| 50 | const hit = li.dataset.path.toLowerCase().includes(q); |
| 51 | li.classList.toggle('filtered-out', !hit); |
| 52 | }); |
| 53 | dirs.forEach((li) => { |
| 54 | const anyVisible = li.querySelector('.tree-file:not(.filtered-out)'); |
| 55 | li.classList.toggle('filtered-out', !anyVisible); |
| 56 | const d = li.querySelector('details'); |
| 57 | if (d && anyVisible) d.open = true; |
| 58 | }); |
| 59 | }); |
| 60 | // Keep the current file in view. |
| 61 | const current = $('.tree-link.current', tree); |
| 62 | if (current) current.scrollIntoView({ block: 'center' }); |
| 63 | } |
| 64 | |
| 65 | /* ---------- file finder dialog ---------- */ |
| 66 | const dialog = $('#search-dialog'); |
| 67 | const searchInput = $('#search-input'); |
| 68 | const results = $('#search-results'); |
| 69 | let fileIndex = null; |
| 70 | let activeIdx = -1; |
| 71 | |
| 72 | async function loadIndex() { |
| 73 | if (fileIndex) return fileIndex; |
| 74 | const res = await fetch('/api/tree'); |
| 75 | fileIndex = await res.json(); |
| 76 | return fileIndex; |
| 77 | } |
| 78 | function fmtBytes(n) { |
| 79 | if (n < 1024) return n + ' B'; |
| 80 | if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; |
| 81 | return (n / 1024 / 1024).toFixed(1) + ' MB'; |
| 82 | } |
| 83 | function escapeHtml(s) { |
| 84 | return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| 85 | } |
| 86 | function score(path, q) { |
| 87 | const p = path.toLowerCase(); |
| 88 | const name = p.slice(p.lastIndexOf('/') + 1); |
| 89 | if (name === q) return 100; |
| 90 | if (name.startsWith(q)) return 80; |
| 91 | if (name.includes(q)) return 60; |
| 92 | if (p.includes(q)) return 40; |
| 93 | // subsequence match |
| 94 | let i = 0; |
| 95 | for (const ch of p) if (ch === q[i]) i++; |
| 96 | return i === q.length ? 10 : -1; |
| 97 | } |
| 98 | function highlight(text, q) { |
| 99 | const i = text.toLowerCase().indexOf(q); |
| 100 | if (i === -1) return escapeHtml(text); |
| 101 | return escapeHtml(text.slice(0, i)) + '<mark>' + escapeHtml(text.slice(i, i + q.length)) + '</mark>' + escapeHtml(text.slice(i + q.length)); |
| 102 | } |
| 103 | function renderResults(q) { |
| 104 | if (!fileIndex) return; |
| 105 | const query = q.trim().toLowerCase(); |
| 106 | let list = fileIndex; |
| 107 | if (query) { |
| 108 | list = fileIndex |
| 109 | .map((f) => ({ f, s: score(f.p, query) })) |
| 110 | .filter((x) => x.s >= 0) |
| 111 | .sort((a, b) => b.s - a.s || a.f.p.length - b.f.p.length) |
| 112 | .map((x) => x.f); |
| 113 | } |
| 114 | list = list.slice(0, 40); |
| 115 | activeIdx = list.length ? 0 : -1; |
| 116 | if (!list.length) { results.innerHTML = '<li class="search-empty">No files match.</li>'; return; } |
| 117 | results.innerHTML = list |
| 118 | .map((f, i) => { |
| 119 | const slash = f.p.lastIndexOf('/'); |
| 120 | const dir = slash === -1 ? '' : f.p.slice(0, slash + 1); |
| 121 | const name = f.p.slice(slash + 1); |
| 122 | const href = '/files/' + f.p.split('/').map(encodeURIComponent).join('/'); |
| 123 | return `<li class="${i === 0 ? 'active' : ''}"><a href="${href}"><span class="sr-dir">${highlight(dir, query)}</span><span>${highlight(name, query)}</span><span class="sr-size">${fmtBytes(f.s)}</span></a></li>`; |
| 124 | }) |
| 125 | .join(''); |
| 126 | } |
| 127 | function openSearch() { |
| 128 | if (!dialog) return; |
| 129 | dialog.showModal(); |
| 130 | searchInput.value = ''; |
| 131 | searchInput.focus(); |
| 132 | loadIndex().then(() => renderResults('')); |
| 133 | } |
| 134 | const searchOpen = $('#search-open'); |
| 135 | if (searchOpen) searchOpen.addEventListener('click', openSearch); |
| 136 | if (searchInput) { |
| 137 | searchInput.addEventListener('input', () => renderResults(searchInput.value)); |
| 138 | searchInput.addEventListener('keydown', (e) => { |
| 139 | const items = $$('li', results).filter((li) => !li.classList.contains('search-empty')); |
| 140 | if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { |
| 141 | e.preventDefault(); |
| 142 | if (!items.length) return; |
| 143 | items[activeIdx]?.classList.remove('active'); |
| 144 | activeIdx = (activeIdx + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length; |
| 145 | items[activeIdx].classList.add('active'); |
| 146 | items[activeIdx].scrollIntoView({ block: 'nearest' }); |
| 147 | } else if (e.key === 'Enter') { |
| 148 | const a = items[activeIdx]?.querySelector('a'); |
| 149 | if (a) { e.preventDefault(); window.location.href = a.href; } |
| 150 | } |
| 151 | }); |
| 152 | } |
| 153 | document.addEventListener('keydown', (e) => { |
| 154 | const tag = (e.target.tagName || '').toLowerCase(); |
| 155 | if (tag === 'input' || tag === 'textarea' || e.metaKey || e.ctrlKey || e.altKey) { |
| 156 | if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openSearch(); } |
| 157 | return; |
| 158 | } |
| 159 | if (e.key === '/') { e.preventDefault(); openSearch(); } |
| 160 | }); |
| 161 | |
| 162 | /* ---------- copy buttons ---------- */ |
| 163 | function flash(btn, label) { |
| 164 | const old = btn.textContent; |
| 165 | btn.textContent = label; |
| 166 | btn.classList.add('done'); |
| 167 | setTimeout(() => { btn.textContent = old; btn.classList.remove('done'); }, 1300); |
| 168 | } |
| 169 | document.addEventListener('click', (e) => { |
| 170 | const btn = e.target.closest('[data-copy], [data-copy-text]'); |
| 171 | if (!btn) return; |
| 172 | let text = btn.dataset.copyText; |
| 173 | if (text === undefined) { |
| 174 | const code = btn.parentElement.querySelector('code'); |
| 175 | text = code ? code.textContent : ''; |
| 176 | } |
| 177 | navigator.clipboard.writeText(text).then(() => flash(btn, 'Copied'), () => flash(btn, 'Failed')); |
| 178 | }); |
| 179 | |
| 180 | /* ---------- code view: wrap + line ranges ---------- */ |
| 181 | const codeView = $('.code-view'); |
| 182 | const wrapBtn = $('#wrap-toggle'); |
| 183 | if (codeView && wrapBtn) { |
| 184 | let wrap = false; |
| 185 | try { wrap = localStorage.getItem('codewrap') === '1'; } catch (e) { /* ignore */ } |
| 186 | codeView.classList.toggle('wrap', wrap); |
| 187 | wrapBtn.classList.toggle('is-on', wrap); |
| 188 | wrapBtn.addEventListener('click', () => { |
| 189 | wrap = !wrap; |
| 190 | codeView.classList.toggle('wrap', wrap); |
| 191 | wrapBtn.classList.toggle('is-on', wrap); |
| 192 | try { localStorage.setItem('codewrap', wrap ? '1' : '0'); } catch (e) { /* ignore */ } |
| 193 | }); |
| 194 | } |
| 195 | if (codeView) { |
| 196 | let anchor = null; |
| 197 | function applyRange() { |
| 198 | $$('tr.hl', codeView).forEach((tr) => tr.classList.remove('hl')); |
| 199 | const m = /^#L(\d+)(?:-L?(\d+))?$/.exec(location.hash); |
| 200 | if (!m) return; |
| 201 | const a = Number(m[1]); |
| 202 | const b = m[2] ? Number(m[2]) : a; |
| 203 | const [lo, hi] = a <= b ? [a, b] : [b, a]; |
| 204 | for (let i = lo; i <= hi; i++) { |
| 205 | const tr = document.getElementById('L' + i); |
| 206 | if (tr) tr.classList.add('hl'); |
| 207 | } |
| 208 | } |
| 209 | codeView.addEventListener('click', (e) => { |
| 210 | const a = e.target.closest('td.ln a'); |
| 211 | if (!a) return; |
| 212 | e.preventDefault(); |
| 213 | const n = Number(a.dataset.line); |
| 214 | if (e.shiftKey && anchor !== null) { |
| 215 | history.replaceState(null, '', `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`); |
| 216 | } else { |
| 217 | anchor = n; |
| 218 | history.replaceState(null, '', `#L${n}`); |
| 219 | } |
| 220 | applyRange(); |
| 221 | }); |
| 222 | window.addEventListener('hashchange', applyRange); |
| 223 | applyRange(); |
| 224 | const first = $('tr.hl', codeView); |
| 225 | if (first) first.scrollIntoView({ block: 'center' }); |
| 226 | } |
| 227 | |
| 228 | /* ---------- heading anchors + TOC scroll-spy ---------- */ |
| 229 | $$('.markdown-body h1[id], .markdown-body h2[id], .markdown-body h3[id], .markdown-body h4[id]').forEach((h) => { |
| 230 | const a = document.createElement('a'); |
| 231 | a.className = 'anchor'; |
| 232 | a.href = '#' + h.id; |
| 233 | a.textContent = '#'; |
| 234 | a.setAttribute('aria-hidden', 'true'); |
| 235 | h.prepend(a); |
| 236 | }); |
| 237 | const tocLinks = $$('.toc a'); |
| 238 | if (tocLinks.length) { |
| 239 | const byId = new Map(tocLinks.map((a) => [decodeURIComponent(a.hash.slice(1)), a.parentElement])); |
| 240 | const targets = Array.from(byId.keys()).map((id) => document.getElementById(id)).filter(Boolean); |
| 241 | let active = null; |
| 242 | const setActive = (li) => { |
| 243 | if (li === active) return; |
| 244 | if (active) active.classList.remove('active'); |
| 245 | active = li; |
| 246 | if (li) { li.classList.add('active'); li.scrollIntoView({ block: 'nearest' }); } |
| 247 | }; |
| 248 | const onScroll = () => { |
| 249 | const top = 70; |
| 250 | let best = null; |
| 251 | for (const t of targets) { |
| 252 | if (t.getBoundingClientRect().top - top <= 0) best = t; |
| 253 | else break; |
| 254 | } |
| 255 | setActive(best ? byId.get(best.id) : null); |
| 256 | }; |
| 257 | document.addEventListener('scroll', onScroll, { passive: true }); |
| 258 | onScroll(); |
| 259 | } |
| 260 | })(); |