|
55 | 55 | if (offcanvasEl) { |
56 | 56 | offcanvasEl.addEventListener('click', function (e) { |
57 | 57 | const a = e.target.closest('a'); |
58 | | - if (!a) return; // ignore clicks on accordion buttons etc. |
| 58 | + if (!a) return; |
59 | 59 | const offcanvas = bootstrap.Offcanvas.getInstance(offcanvasEl) || |
60 | 60 | new bootstrap.Offcanvas(offcanvasEl); |
61 | | - // Close for internal links (no target="_blank") |
62 | 61 | if (a.getAttribute('href') && !a.getAttribute('target')) { |
63 | 62 | offcanvas.hide(); |
64 | 63 | } |
65 | 64 | }); |
66 | 65 | } |
67 | 66 |
|
68 | | - /* 5) Mega dropdown accordions: ensure one-open-at-a-time via data-bs-parent */ |
| 67 | + /* 5) Mega dropdown accordions */ |
69 | 68 | document.querySelectorAll('.dropdown-menu.mega .accordion').forEach((acc, i) => { |
70 | | - // Make sure the accordion has an id |
71 | 69 | if (!acc.id) acc.id = `mega-acc-${i}`; |
72 | 70 | const parentSel = `#${acc.id}`; |
73 | | - |
74 | | - // For each collapse pane, set data-bs-parent if missing |
75 | 71 | acc.querySelectorAll('.accordion-collapse').forEach(col => { |
76 | 72 | if (!col.getAttribute('data-bs-parent')) { |
77 | 73 | col.setAttribute('data-bs-parent', parentSel); |
78 | 74 | } |
79 | 75 | }); |
80 | 76 | }); |
81 | 77 |
|
82 | | - /* 6) Keep clicks inside mega from bubbling to the dropdown toggle (belt & suspenders). |
83 | | - With data-bs-auto-close="outside" this isn’t strictly needed, but it’s harmless. */ |
| 78 | + /* 6) Stop mega menu click bubbling */ |
84 | 79 | document.querySelectorAll('.dropdown-menu.mega').forEach(menu => { |
85 | 80 | menu.addEventListener('click', (e) => e.stopPropagation()); |
86 | 81 | }); |
| 82 | + |
| 83 | + /* ========================================================= |
| 84 | + 7) Client-side Text-to-Speech (Listen to Article) |
| 85 | + ========================================================= */ |
| 86 | + if ('speechSynthesis' in window) { |
| 87 | + const ttsBtn = document.getElementById('tts-btn'); |
| 88 | + let speaking = false; |
| 89 | + let utterance = null; |
| 90 | + |
| 91 | + function getArticleText() { |
| 92 | + const main = document.querySelector('main'); |
| 93 | + return main ? main.innerText : ''; |
| 94 | + } |
| 95 | + |
| 96 | + if (ttsBtn) { |
| 97 | + ttsBtn.addEventListener('click', function () { |
| 98 | + if (speaking) { |
| 99 | + window.speechSynthesis.cancel(); |
| 100 | + speaking = false; |
| 101 | + ttsBtn.textContent = 'Listen to article'; |
| 102 | + return; |
| 103 | + } |
| 104 | + |
| 105 | + const text = getArticleText(); |
| 106 | + if (!text) return; |
| 107 | + |
| 108 | + utterance = new SpeechSynthesisUtterance(text); |
| 109 | + utterance.lang = 'en-US'; |
| 110 | + utterance.rate = 1; |
| 111 | + |
| 112 | + utterance.onend = function () { |
| 113 | + speaking = false; |
| 114 | + ttsBtn.textContent = 'Listen to article'; |
| 115 | + }; |
| 116 | + |
| 117 | + window.speechSynthesis.speak(utterance); |
| 118 | + speaking = true; |
| 119 | + ttsBtn.textContent = 'Stop listening'; |
| 120 | + }); |
| 121 | + } |
| 122 | + } |
87 | 123 | }); |
88 | 124 |
|
89 | 125 | // Optional public API |
|
99 | 135 | document.querySelectorAll('a[href^="http"]').forEach(a => { |
100 | 136 | try { |
101 | 137 | const url = new URL(a.href); |
102 | | - // skip same-origin |
103 | 138 | if (url.origin === window.location.origin) return; |
104 | 139 | if (!a.hasAttribute('target')) a.setAttribute('target', '_blank'); |
105 | 140 | if (!a.hasAttribute('rel')) a.setAttribute('rel', 'noopener'); |
106 | 141 | } catch (_) {} |
107 | 142 | }); |
108 | 143 |
|
109 | | - // B) small “copy code” buttons for pygments blocks (MkDocs default markup) |
| 144 | + // B) copy code buttons |
110 | 145 | document.querySelectorAll('div.highlight > pre').forEach((pre, i) => { |
111 | | - // container for the button |
112 | | - const wrap = pre.parentElement; // .highlight |
| 146 | + const wrap = pre.parentElement; |
113 | 147 | wrap.style.position = 'relative'; |
114 | 148 |
|
115 | 149 | const btn = document.createElement('button'); |
|
126 | 160 | btn.textContent = 'Copied!'; |
127 | 161 | setTimeout(() => (btn.textContent = old), 1200); |
128 | 162 | } catch (e) { |
129 | | - // fallback |
130 | 163 | const ta = document.createElement('textarea'); |
131 | 164 | ta.value = code; |
132 | 165 | ta.style.position = 'fixed'; |
|
144 | 177 | wrap.appendChild(btn); |
145 | 178 | }); |
146 | 179 |
|
147 | | - // C) heading anchors (h2–h4) inside main content |
| 180 | + // C) heading anchors |
148 | 181 | const contentRoot = document.querySelector('main .content-inner') || document.querySelector('main'); |
149 | 182 | if (contentRoot) { |
150 | 183 | contentRoot.querySelectorAll('h2[id], h3[id], h4[id]').forEach(h => { |
151 | | - if (h.querySelector('a.anchor-link')) return; // idempotent |
| 184 | + if (h.querySelector('a.anchor-link')) return; |
152 | 185 | const a = document.createElement('a'); |
153 | 186 | a.href = `#${h.id}`; |
154 | 187 | a.className = 'anchor-link ms-2'; |
155 | 188 | a.setAttribute('aria-label', 'Copy link to this section'); |
156 | | - a.innerHTML = '¶'; // simple mark; you can swap for an SVG if you prefer |
157 | | - a.addEventListener('click', (e) => { |
158 | | - // let it navigate, then copy |
| 189 | + a.innerHTML = '¶'; |
| 190 | + a.addEventListener('click', () => { |
159 | 191 | setTimeout(() => navigator.clipboard.writeText(window.location.href), 0); |
160 | 192 | }); |
161 | 193 | h.appendChild(a); |
|
167 | 199 |
|
168 | 200 | // --- Search results link absolutizer --- |
169 | 201 | (function () { |
170 | | - // Which containers might hold search result links? |
171 | 202 | const candidates = [ |
172 | 203 | '.mk-search-results', |
173 | 204 | '.search-results', |
|
176 | 207 |
|
177 | 208 | function absolutize(href) { |
178 | 209 | if (!href) return href; |
179 | | - if (/^([a-z]+:)?\/\//i.test(href)) return href; // already absolute URL |
180 | | - if (href.startsWith('/')) return href; // already site-absolute |
181 | | - return '/' + href.replace(/^\/+/, ''); // make it site-absolute |
| 210 | + if (/^([a-z]+:)?\/\//i.test(href)) return href; |
| 211 | + if (href.startsWith('/')) return href; |
| 212 | + return '/' + href.replace(/^\/+/, ''); |
182 | 213 | } |
183 | 214 |
|
184 | 215 | function fixLinks(root = document) { |
185 | 216 | candidates.forEach(sel => { |
186 | 217 | root.querySelectorAll(`${sel} a[href]`).forEach(a => { |
187 | 218 | const fixed = absolutize(a.getAttribute('href')); |
188 | | - if (fixed && fixed !== a.getAttribute('href')) a.setAttribute('href', fixed); |
| 219 | + if (fixed && fixed !== a.getAttribute('href')) { |
| 220 | + a.setAttribute('href', fixed); |
| 221 | + } |
189 | 222 | }); |
190 | 223 | }); |
191 | 224 | } |
192 | 225 |
|
193 | | - // 1) Run once after DOM ready (in case results render immediately) |
194 | 226 | if (document.readyState === 'loading') { |
195 | 227 | document.addEventListener('DOMContentLoaded', () => fixLinks()); |
196 | 228 | } else { |
197 | 229 | fixLinks(); |
198 | 230 | } |
199 | 231 |
|
200 | | - // 2) Watch for results being (re)rendered |
201 | 232 | const obs = new MutationObserver(muts => { |
202 | 233 | for (const m of muts) { |
203 | | - if (m.type === 'childList' && (m.addedNodes && m.addedNodes.length)) { |
| 234 | + if (m.type === 'childList' && m.addedNodes.length) { |
204 | 235 | fixLinks(document); |
205 | 236 | } |
206 | 237 | } |
|
0 commit comments