v2 / cmd / tools / vdoc / theme / doc.js
327 lines · 318 sloc · 10.64 KB · caa0c46484cb3e59f5ca5d8fbb229b7ebbae023c
Raw
1(function () {
2 const docnav = document.querySelector('header.doc-nav');
3 const active = docnav.querySelector('li.active');
4 active?.scrollIntoView({ block: 'center', inline: 'nearest' });
5 setupMobileToggle();
6 setupDarkMode();
7 setupScrollSpy();
8 setupSearch();
9 setupCollapse();
10 setupCodeCopy();
11})();
12
13function setupScrollSpy() {
14 const mainContent = document.querySelector('#main-content');
15 const toc = mainContent.querySelector('.doc-toc');
16 if (!toc) {
17 return;
18 }
19 const sections = mainContent.querySelectorAll('section');
20 const sectionPositions = Array.from(sections).map((section) => section.offsetTop);
21 let lastActive = null;
22 let clickedScroll = false;
23 const handleScroll = debounce(() => {
24 if (clickedScroll) {
25 clickedScroll = false;
26 return;
27 }
28 if (lastActive) {
29 lastActive.classList.remove('active');
30 }
31 for (const [i, position] of sectionPositions.entries()) {
32 if (position >= mainContent.scrollTop) {
33 const link = toc.querySelector('a[href="#' + sections[i].id + '"]');
34 if (link) {
35 // Set current menu link as active
36 link.classList.add('active');
37 const tocStart = toc.getBoundingClientRect().top + window.scrollY;
38 if (link.offsetTop > toc.scrollTop + toc.clientHeight - tocStart - 16) {
39 // Scroll the toc down if the active links position is below the bottom of the toc
40 toc.scrollTop = link.clientHeight + link.offsetTop - toc.clientHeight + tocStart + 10;
41 } else if (toc.scrollTop < 32 + tocStart) {
42 // Scroll to the top of the toc if having scrolled up into the last bit
43 toc.scrollTop = 0;
44 } else if (link.offsetTop < toc.scrollTop) {
45 // Scroll the toc up if the active links position is above the top of the toc
46 toc.scrollTop = link.offsetTop - 10;
47 }
48 }
49 lastActive = link;
50 break;
51 }
52 }
53 }, 10);
54 mainContent.addEventListener('scroll', handleScroll);
55 toc.querySelectorAll('a').forEach((a) =>
56 a.addEventListener('click', () => {
57 if (lastActive) {
58 lastActive.classList.remove('active');
59 }
60 a.classList.add('active');
61 lastActive = a;
62 clickedScroll = true;
63 }),
64 );
65}
66
67function setupMobileToggle() {
68 document.getElementById('toggle-menu').addEventListener('click', () => {
69 const docNav = document.querySelector('header.doc-nav');
70 const isHidden = docNav.classList.contains('hidden');
71 docNav.classList.toggle('hidden');
72 const search = docNav.querySelector('.search');
73 const searchHasResults = search.classList.contains('has-results');
74 if (isHidden && searchHasResults) {
75 search.classList.remove('mobile-hidden');
76 } else {
77 search.classList.add('mobile-hidden');
78 }
79 const content = docNav.querySelector('.content');
80 content.classList.toggle('hidden');
81 content.classList.toggle('show');
82 });
83}
84
85function setupDarkMode() {
86 const html = document.querySelector('html');
87 const darkModeToggle = document.getElementById('dark-mode-toggle');
88 darkModeToggle.addEventListener('click', () => {
89 html.classList.toggle('dark');
90 const isDarkModeEnabled = html.classList.contains('dark');
91 localStorage.setItem('dark-mode', isDarkModeEnabled);
92 darkModeToggle.setAttribute('aria-checked', isDarkModeEnabled);
93 });
94}
95
96function setupSearch() {
97 const onInputChange = debounce((e) => {
98 const searchValue = e.target.value.toLowerCase();
99 const docNav = document.querySelector('header.doc-nav');
100 const menu = docNav.querySelector('.content');
101 const search = docNav.querySelector('.search');
102 if (searchValue === '') {
103 // reset to default
104 menu.style.display = '';
105 if (!search.classList.contains('hidden')) {
106 search.classList.add('hidden');
107 search.classList.remove('has-results');
108 }
109 } else if (searchValue.length >= 2) {
110 // search for less than 2 characters can display too much results
111 search.innerHTML = '';
112 menu.style.display = 'none';
113 if (search.classList.contains('hidden')) {
114 search.classList.remove('hidden');
115 search.classList.remove('mobile-hidden');
116 search.classList.add('has-results');
117 }
118 // cache length for performance
119 let foundModule = false;
120 const ul = document.createElement('ul');
121 search.appendChild(ul);
122 for (const [i, title] of searchModuleIndex.entries()) {
123 // no toLowerCase needed because modules are always lowercase
124 if (title.indexOf(searchValue) === -1) {
125 continue;
126 }
127 foundModule = true;
128 // [description, link]
129 const data = searchModuleData[i];
130 const el = createSearchResult({
131 badge: 'module',
132 description: data[0],
133 link: data[1],
134 title: title,
135 });
136 ul.appendChild(el);
137 }
138 if (foundModule) {
139 const hr = document.createElement('hr');
140 hr.classList.add('separator');
141 search.appendChild(hr);
142 }
143 let results = [];
144 for (const [i, title] of searchIndex.entries()) {
145 if (title.toLowerCase().indexOf(searchValue) === -1) {
146 continue;
147 }
148 // [badge, description, link]
149 const data = searchData[i];
150 results.push({
151 badge: data[0],
152 description: data[1],
153 link: data[2],
154 title: data[3] + ' ' + title,
155 });
156 }
157 results.sort((a, b) => (a.title < b.title ? -1 : a.title > b.title ? 1 : 0));
158 const ul_ = document.createElement('ul');
159 search.appendChild(ul_);
160 results.forEach((result) => {
161 const el = createSearchResult(result);
162 ul_.appendChild(el);
163 });
164 }
165 });
166 const searchInput = document.querySelector('#search input');
167 const url = document.location.toString();
168 if (url.includes('?')) {
169 const query =
170 url
171 .split('?')
172 .slice(1)
173 .filter((p) => p.startsWith('q='))
174 .map((p) => p.replace(/^q=/, ''))[0] || '';
175 if (query) {
176 searchInput.value = query;
177 searchInput.focus();
178 onInputChange({ target: { value: query } });
179 }
180 }
181 const searchInputDiv = document.getElementById('search');
182 searchInputDiv.addEventListener('input', onInputChange);
183 setupSearchKeymaps();
184}
185
186function setupSearchKeymaps() {
187 const searchInput = document.querySelector('#search input');
188 const mainContent = document.querySelector('#main-content');
189 const docnav = document.querySelector('header.doc-nav');
190 // Keyboard shortcut indicator
191 const searchKeys = document.createElement('div');
192 const modifierKeyPrefix = navigator.platform.includes('Mac') ? '⌘' : 'Ctrl';
193 searchKeys.setAttribute('id', 'search-keys');
194 searchKeys.innerHTML = '<kbd>' + modifierKeyPrefix + '</kbd><kbd>k</kbd>';
195 searchInput.parentElement?.appendChild(searchKeys);
196 searchInput.addEventListener('focus', () => searchKeys.classList.add('hide'));
197 searchInput.addEventListener('blur', () => searchKeys.classList.remove('hide'));
198 // Global shortcuts to focus searchInput
199 document.addEventListener('keydown', (ev) => {
200 if (ev.key === '/' || ((ev.ctrlKey || ev.metaKey) && ev.key === 'k')) {
201 ev.preventDefault();
202 searchInput.focus();
203 }
204 });
205 // Shortcuts while searchInput is focused
206 let selectedIdx = -1;
207 function selectResult(results, newIdx) {
208 if (selectedIdx !== -1) {
209 results[selectedIdx].classList.remove('selected');
210 }
211 results[newIdx].classList.add('selected');
212 results[newIdx].scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' });
213 selectedIdx = newIdx;
214 }
215 searchInput.addEventListener('keydown', (ev) => {
216 const searchResults = document.querySelectorAll('.search .result');
217 switch (ev.key) {
218 case 'Escape':
219 searchInput.blur();
220 mainContent.focus();
221 break;
222 case 'Enter':
223 if (!searchResults.length || selectedIdx === -1) break;
224 searchResults[selectedIdx].querySelector('a').click();
225 break;
226 case 'ArrowDown':
227 ev.preventDefault();
228 if (!searchResults.length) break;
229 if (selectedIdx >= searchResults.length - 1) {
230 // Cycle to first if last is selected
231 selectResult(searchResults, 0);
232 } else {
233 // Select next
234 selectResult(searchResults, selectedIdx + 1);
235 }
236 break;
237 case 'ArrowUp':
238 ev.preventDefault();
239 if (!searchResults.length) break;
240 if (selectedIdx <= 0) {
241 // Cycle to last if first is selected (or select it if none is selected yet)
242 selectResult(searchResults, searchResults.length - 1);
243 } else {
244 // Select previous
245 selectResult(searchResults, selectedIdx - 1);
246 }
247 break;
248 default:
249 docnav.scroll(0, 0);
250 selectedIdx = -1;
251 }
252 });
253 // Ensure initial keyboard navigability
254 mainContent.focus();
255}
256
257function createSearchResult(data) {
258 const li = document.createElement('li');
259 li.classList.add('result');
260 const a = document.createElement('a');
261 a.href = data.link;
262 a.classList.add('link');
263 li.appendChild(a);
264 const definition = document.createElement('div');
265 definition.classList.add('definition');
266 a.appendChild(definition);
267 if (data.description) {
268 const description = document.createElement('div');
269 description.classList.add('description');
270 description.textContent = data.description;
271 a.appendChild(description);
272 }
273 const title = document.createElement('span');
274 title.classList.add('title');
275 title.textContent = data.title;
276 definition.appendChild(title);
277 const badge = document.createElement('badge');
278 badge.classList.add('badge');
279 badge.textContent = data.badge;
280 definition.appendChild(badge);
281 return li;
282}
283
284function setupCollapse() {
285 const dropdownArrows = document.querySelectorAll('.dropdown-arrow');
286 dropdownArrows.forEach((arrow) => {
287 arrow.addEventListener('click', (e) => {
288 const parent = e.target.parentElement.parentElement.parentElement;
289 parent.classList.toggle('open');
290 });
291 });
292}
293
294function debounce(func, timeout) {
295 let timer;
296 return (...args) => {
297 const next = () => func(...args);
298 if (timer) {
299 clearTimeout(timer);
300 }
301 timer = setTimeout(next, timeout > 0 ? timeout : 300);
302 };
303}
304
305function setupCodeCopy() {
306 const pres = document.querySelectorAll('pre:not(.signature)');
307 pres.forEach((pre) => {
308 const tempDiv = document.createElement('button');
309 tempDiv.className = 'copy';
310 tempDiv.innerHTML =
311 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="rgba(173,184,194,1)"><path d="M6.9998 6V3C6.9998 2.44772 7.44752 2 7.9998 2H19.9998C20.5521 2 20.9998 2.44772 20.9998 3V17C20.9998 17.5523 20.5521 18 19.9998 18H16.9998V20.9991C16.9998 21.5519 16.5499 22 15.993 22H4.00666C3.45059 22 3 21.5554 3 20.9991L3.0026 7.00087C3.0027 6.44811 3.45264 6 4.00942 6H6.9998ZM5.00242 8L5.00019 20H14.9998V8H5.00242ZM8.9998 6H16.9998V16H18.9998V4H8.9998V6Z"></path></svg>';
312 tempDiv.addEventListener('click', (e) => {
313 const parent = e.target;
314 var code = tempDiv.parentElement.querySelector('code');
315 let i = Array.from(code.childNodes)
316 .map((r) => r.textContent)
317 .join('');
318 navigator.clipboard.writeText(i);
319 var tmp = tempDiv.innerHTML;
320 tempDiv.innerHTML = 'Copied';
321 window.setTimeout(function () {
322 tempDiv.innerHTML = tmp;
323 }, 1000);
324 });
325 pre.insertAdjacentElement('afterbegin', tempDiv);
326 });
327}
328