Ticket #15430: Haiku-tour.js

File Haiku-tour.js, 8.3 KB (added by madmax, 4 years ago)
Line 
1/*
2MIT License
3
4Copyright (c) 2020 Haiku, Inc.
5
6Permission is hereby granted, free of charge, to any person obtaining a copy
7of this software and associated documentation files (the "Software"), to deal
8in the Software without restriction, including without limitation the rights
9to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10copies of the Software, and to permit persons to whom the Software is
11furnished to do so, subject to the following conditions:
12
13The above copyright notice and this permission notice shall be included in all
14copies or substantial portions of the Software.
15
16THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22SOFTWARE.
23*/
24
25;(function (window, doc) {
26
27 const bodyClassList = doc.getElementsByTagName('body')[0].classList;
28 const contentDiv = doc.getElementById('content');
29 const passive = { passive: true };
30
31 let scrollTimeout = null;
32 let internalHashChange = false;
33 let currentTopic = null;
34
35 function slides() {
36 return bodyClassList.contains('slides');
37 }
38
39 function toggleFormat() {
40 processPendingScroll();
41 bodyClassList.toggle('slides');
42 topics.get(currentTopic).slide.scrollIntoView(true);
43 setLocationHash('');
44 }
45
46 function nextTopic() {
47 processPendingScroll();
48 const topic = topics.get(currentTopic);
49 if (topic != null && topic.next != null) {
50 setTopic(topic.next);
51 topics.get(topic.next).slide.scrollIntoView(true);
52 }
53 }
54
55 function previousTopic() {
56 processPendingScroll();
57 const topic = topics.get(currentTopic);
58 if (topic != null && topic.prev != null) {
59 setTopic(topic.prev);
60 topics.get(topic.prev).slide.scrollIntoView(true);
61 }
62 }
63
64 /* The translation tool wants exactly <div id="content">\n<div>
65 * to insert its "translation incomplete" box. Removing the topic
66 * class from the welcome page breaks the slide layout. Adding it
67 * back on load only shows the box in the first slide. Having an
68 * extra div when there's no box adds too much whitespace to the
69 * top of the slides. So we remove it here if it has no content.
70 */
71 {
72 let emptyDiv = doc.querySelector('#content>div:empty');
73 if (emptyDiv != null) {
74 emptyDiv.remove();
75 }
76 }
77
78 let topics = new Map();
79 {
80 let prev = null;
81 for (let topicDiv of doc.querySelectorAll('div.topic')) {
82 let topic = '#' + topicDiv.querySelector('h1 a').name;
83 topics.set(topic, {
84 slide: topicDiv,
85 navdot: null,
86 next: null,
87 prev: prev,
88 });
89 prev = topic;
90 }
91 for (let dot of doc.querySelectorAll('.navdots a acronym')) {
92 topics.get((new URL(dot.parentNode.href)).hash).navdot = dot;
93 }
94 for (let [k,v] of topics) {
95 prev = v.prev;
96 if (prev !== null) {
97 topics.get(prev).next = k;
98 }
99 }
100 }
101
102 function setTopic(hash) {
103 if (hash == null || hash.length == 0) {
104 hash = topics.keys().next().value;
105 } else if (!topics.has(hash)) {
106 hash = hash.substring(1);
107 let element = doc.getElementById(hash)
108 || doc.querySelector('[name='+hash+']');
109 if (element != null) {
110 element = element.closest('div.topic');
111 }
112 if (element != null) {
113 hash = '#' + element.querySelector('h1 a').name;
114 }
115 if (!topics.has(hash)) {
116 hash = topics.keys().next().value;
117 }
118 }
119
120 if (hash === currentTopic) return;
121
122 let topic;
123 if (currentTopic !== null) {
124 topic = topics.get(currentTopic);
125 topic.slide.classList.remove('current-page');
126 topic.navdot.classList.remove('current-page');
127 }
128 topic = topics.get(hash);
129 topic.slide.classList.add('current-page');
130 topic.navdot.classList.add('current-page');
131 currentTopic = hash;
132 }
133
134
135 function checkPosition() {
136 const containerRect = contentDiv.getBoundingClientRect();
137 const minTop = containerRect.top;
138 const maxTop = minTop + (containerRect.bottom - minTop) / 3;
139 let bestValue = -9999;
140 let bestTopic = null;
141 for (let [topic, v] of topics) {
142 const topicRect = v.slide.getBoundingClientRect();
143 if (topicRect.top < maxTop) {
144 if (topicRect.top >= minTop) {
145 if (topicRect.top < bestValue || bestValue < minTop) {
146 bestValue = topicRect.top;
147 bestTopic = topic;
148 }
149 } else if (topicRect.top > bestValue) {
150 bestValue = topicRect.top;
151 bestTopic = topic;
152 }
153 }
154 }
155 if (bestTopic !== null) {
156 setTopic(bestTopic);
157 }
158 }
159
160 function checkScroll() {
161 scrollTimeout = null;
162 if (!slides()) {
163 checkPosition();
164 }
165 }
166
167 function processPendingScroll() {
168 if (scrollTimeout != null) {
169 window.clearTimeout(scrollTimeout);
170 checkScroll();
171 }
172 }
173
174 function positionChangeHandler() {
175 if (scrollTimeout === null) {
176 scrollTimeout = window.setTimeout(checkScroll, 200);
177 }
178 }
179
180 contentDiv.addEventListener('scroll', positionChangeHandler, passive);
181 window.addEventListener('resize', positionChangeHandler, passive);
182 window.addEventListener('orientationchange', positionChangeHandler, passive);
183
184
185 function setLocationHash(hash) {
186 if (window.location.hash != hash) {
187 internalHashChange = true;
188 window.location.hash = hash;
189 }
190 }
191
192 window.addEventListener('hashchange', function() {
193 if (!internalHashChange) {
194 setTopic(window.location.hash);
195 }
196 internalHashChange = false;
197 }, passive);
198
199 doc.getElementById('toggle').addEventListener(
200 'click', toggleFormat, passive);
201 doc.getElementById('prevtopic').addEventListener(
202 'click', previousTopic, passive);
203 doc.getElementById('nexttopic').addEventListener(
204 'click', nextTopic, passive);
205 doc.getElementById('prevtopic-bottom').addEventListener(
206 'click', previousTopic, passive);
207 doc.getElementById('nexttopic-bottom').addEventListener(
208 'click', nextTopic, passive);
209
210 for (let element of doc.querySelectorAll('.hide-no-js')) {
211 element.classList.remove('hide-no-js');
212 }
213
214 /* Body is fixed, with the scrollable element being #content.
215 * So let's scroll #content with events triggered outside.
216 * No fancy inertia or smooth scrolling, though.
217 */
218 {
219 const targetInContent = (e) => e.target.closest('#content') != null;
220 const lines = (l) => 20 * l;
221 const pages = (p) => p * (contentDiv.getBoundingClientRect().height - lines(1));
222
223 let wantScroll = 0;
224 let lockedScroll = false;
225
226 function lockScroll() {
227 lockedScroll = true;
228 window.setTimeout(function () {
229 lockedScroll = false;
230 }, 400);
231 }
232
233 function doScroll() {
234 if (lockedScroll) {
235 wantScroll = 0;
236 return;
237 }
238
239 let curScroll = contentDiv.scrollTop;
240 contentDiv.scrollTop += wantScroll;
241 if (curScroll == contentDiv.scrollTop) {
242 wantScroll /= lines(3);
243 if (wantScroll > 1) {
244 nextTopic();
245 lockScroll();
246 } else if (wantScroll < -1) {
247 previousTopic();
248 lockScroll();
249 }
250 }
251 wantScroll = 0;
252 }
253
254 function requestScroll(px) {
255 if (wantScroll === 0) {
256 window.requestAnimationFrame(doScroll);
257 }
258 wantScroll += px;
259 }
260
261 function forceScroll(px) {
262 lockedScroll = false;
263 requestScroll(px);
264 }
265
266 doc.addEventListener('wheel', function (event) {
267 if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey)
268 return;
269 if (event.target.closest('.lang-menu') != null)
270 return;
271 switch (event.deltaMode) {
272 case event.DOM_DELTA_PIXEL:
273 requestScroll(event.deltaY);
274 break;
275 case event.DOM_DELTA_PAGE:
276 requestScroll(pages(event.deltaY > 0 ? 1 : -1));
277 break;
278 default:
279 requestScroll(lines(event.deltaY));
280 break;
281 }
282 }, passive);
283 doc.addEventListener('keydown', function (event) {
284 if (!targetInContent(event)) {
285 switch (event.key) {
286 case 'PageDown':
287 forceScroll(pages(1));
288 break;
289 case 'PageUp':
290 forceScroll(-pages(1));
291 break;
292 case 'ArrowDown':
293 requestScroll(lines(1));
294 break;
295 case 'ArrowUp':
296 requestScroll(lines(-1));
297 break;
298 }
299 }
300 if (event.ctrlKey || event.metaKey) {
301 switch (event.key) {
302 case 'ArrowLeft':
303 previousTopic();
304 break;
305 case 'ArrowRight':
306 nextTopic();
307 break;
308 case 'm':
309 toggleFormat();
310 break;
311 }
312 }
313 }, passive);
314 }
315
316 setTopic(window.location.hash);
317
318 // Change to slide mode, except in the translation tool
319 if (typeof source_strings === "undefined") {
320 toggleFormat();
321 }
322
323} (window, document));