pboProperty Ontology
javascript260 lines10.1 KB
RawDownload
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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})();