1/** 2 * Stories: a strip of covers on the page, and a viewer that plays a story. 3 * 4 * One clock, not two. The slide timer *is* the progress bar animation: the bar 5 * ends, the animationend fires, the next slide comes up. The previous version 6 * ran a 4s CSS animation next to an 8s autoplay, so the bar filled and then the 7 * reader watched a finished bar for another four seconds. There is no way for 8 * those to drift apart here, and pausing is one property on one element. 9 * 10 * Every gesture leads somewhere: the tap zones move through the slides, a 11 * sideways swipe moves to the next story, a swipe up opens the article, and the 12 * exit sits visible on every slide. The reader is never asked to tap once just 13 * to be shown the same headline again. 14 * 15 * The viewer is a modal <dialog>. The focus trap, the escape key and the 16 * backdrop come from the browser, and it is painted in the top layer â the one 17 * place the accessibility widget's z-index of 2147483647 cannot cover. 18 */ 19(function () { 20 'use strict'; 21 22 // The slide stays up long enough to read it: a base plus reading time, 23 // bounded so a bare headline is not gone in an instant and a full slide 24 // does not stall the story. 25 var BASE_MS = 4000; 26 var MAX_MS = 9000; 27 var MS_PER_CHARACTER = 35; 28 29 // Press and hold pauses â below this a press is still a tap. 30 var HOLD_MS = 220; 31 var SWIPE_X = 60; 32 var SWIPE_UP = 80; 33 34 function track(name, params) { 35 if (typeof window.gtag === 'function') { 36 window.gtag('event', name, params); 37 } 38 } 39 40 function setUpBlock(block) { 41 var covers = Array.prototype.slice.call(block.querySelectorAll('[data-story-open]')); 42 var templates = Array.prototype.slice.call(block.querySelectorAll('.story__slides')); 43 var viewer = block.querySelector('.story-viewer'); 44 45 if (!covers.length || !templates.length || !viewer) { 46 return; 47 } 48 49 templates.sort(function (a, b) { 50 return Number(a.dataset.storyIndex) - Number(b.dataset.storyIndex); 51 }); 52 53 // A modal dialog is laid out against the viewport whatever its 54 // ancestors do, so this is not what makes the viewer work â it keeps 55 // the shell out of the composer's rows, where it has nothing to do. 56 document.body.appendChild(viewer); 57 58 var frame = viewer.querySelector('.story-viewer__frame'); 59 var stage = viewer.querySelector('.story-viewer__stage'); 60 var progress = viewer.querySelector('.story-viewer__progress'); 61 var cta = viewer.querySelector('.story-viewer__cta'); 62 var closeButton = viewer.querySelector('.story-viewer__close'); 63 var prevStoryButton = viewer.querySelector('[data-story-prev-story]'); 64 var nextStoryButton = viewer.querySelector('[data-story-next-story]'); 65 var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); 66 67 var storyIndex = -1; 68 var slideIndex = 0; 69 var slides = []; 70 var segments = []; 71 var pushedState = false; 72 var opener = null; 73 var holdTimer = null; 74 var pointer = null; 75 var suppressClick = false; 76 77 setUpStrip(); 78 79 // ---------------------------------------------------------------- strip 80 81 function setUpStrip() { 82 // The strip is a CSS rail: sass/components/_rail.scss in the theme 83 // gives it scroll-snap, this file's --rail-columns ladder sets how many 84 // covers stand across, and the theme's js/rail.js drives the two arrows. 85 // A `new Swiper(strip, â¦)` used to sit here and was the only reason the 86 // 147 KB bundle was on the page at all. 87 88 covers.forEach(function (cover) { 89 cover.addEventListener('click', function (event) { 90 // a modified click is the reader asking for the article in a 91 // new tab â the href is real, let the browser have it 92 if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button > 0) { 93 return; 94 } 95 96 event.preventDefault(); 97 open(Number(cover.dataset.storyOpen) || 0, cover); 98 }); 99 }); 100 } 101 102 // --------------------------------------------------------------- viewer 103 104 function open(index, trigger) { 105 opener = trigger || null; 106 107 if (!viewer.open) { 108 viewer.showModal(); 109 } 110 111 document.documentElement.classList.add('story-viewer-is-open'); 112 openStory(index, 0); 113 114 // so the phone's back gesture closes the story instead of leaving 115 // the page 116 if (!pushedState) { 117 window.history.pushState({ jmStoryViewer: true }, ''); 118 pushedState = true; 119 } 120 121 closeButton.focus(); 122 } 123 124 function close() { 125 if (viewer.open) { 126 viewer.close(); 127 } 128 } 129 130 // every way out of the dialog ends here: the exit button, the escape 131 // key the browser handles on its own, a click beside the frame, and the 132 // back gesture 133 viewer.addEventListener('close', function () { 134 setPaused(false); 135 stage.replaceChildren(); 136 progress.replaceChildren(); 137 slides = []; 138 segments = []; 139 storyIndex = -1; 140 document.documentElement.classList.remove('story-viewer-is-open'); 141 142 if (opener) {
143 opener.focus(); 144 opener = null; 145 } 146 147 // popstate clears this first, so returning through history does not 148 // walk back a second time 149 if (pushedState) { 150 pushedState = false; 151 window.history.back(); 152 } 153 }); 154 155 // a click that lands on the dialog itself is a click beside the frame 156 viewer.addEventListener('click', function (event) { 157 if (event.target === viewer) { 158 close(); 159 } 160 }); 161 162 function openStory(index, startAt) { 163 var template = templates[index]; 164 165 if (!template) { 166 return; 167 } 168 169 storyIndex = index; 170 stage.replaceChildren(template.content.cloneNode(true)); 171 slides = Array.prototype.slice.call(stage.querySelectorAll('.story-slide')); 172 buildProgress(slides.length); 173 174 track('story_open', { 175 story_title: template.dataset.storyTitle || '', 176 story_index: index 177 }); 178 179 var link = template.dataset.storyLink || ''; 180 181 if (link) { 182 cta.href = link; 183 cta.textContent = template.dataset.storyCta || 'Zum Beitrag'; 184 cta.hidden = false; 185 } else { 186 cta.removeAttribute('href'); 187 cta.hidden = true; 188 } 189 190 viewer.setAttribute('aria-label', 'Story: ' + (template.dataset.storyTitle || '')); 191 prevStoryButton.disabled = index <= 0; 192 nextStoryButton.disabled = index >= templates.length - 1; 193 194 showSlide(startAt || 0); 195 } 196 197 function buildProgress(count) { 198 progress.replaceChildren(); 199 segments = []; 200 201 for (var i = 0; i < count; i++) { 202 var segment = document.createElement('i'); 203 var bar = document.createElement('b'); 204 205 segment.appendChild(bar); 206 progress.appendChild(segment); 207 segments.push(bar); 208 } 209 } 210 211 function showSlide(index) { 212 slideIndex = index; 213 setPaused(false); 214 215 track('slide_view', { 216 story_title: (templates[storyIndex] && templates[storyIndex].dataset.storyTitle) || '', 217 slide_index: index 218 }); 219 220 slides.forEach(function (slide, i) { 221 slide.classList.toggle('is-active', i === index); 222 }); 223 224 segments.forEach(function (bar, i) { 225 bar.classList.remove('is-running'); 226 bar.classList.toggle('is-done', i < index); 227 }); 228 229 var bar = segments[index]; 230 231 if (!bar) { 232 return; 233 } 234 235 // Someone who asked for less motion did not ask to be hurried 236 // through a story: the bar fills, nothing advances on its own. 237 if (reduceMotion.matches) { 238 bar.classList.add('is-done'); 239 return; 240 } 241 242 frame.style.setProperty('--story-duration', durationFor(slides[index]) + 'ms'); 243 void bar.offsetWidth; 244 bar.classList.add('is-running'); 245 } 246 247 function durationFor(slide) { 248 // the markup arrives pretty-printed, so the indentation between the 249 // tags would otherwise be counted as reading time 250 var text = slide ? (slide.textContent || '').replace(/\s+/g, ' ').trim() : ''; 251 252 return Math.min(MAX_MS, BASE_MS + text.length * MS_PER_CHARACTER); 253 } 254 255 function setPaused(paused) { 256 viewer.classList.toggle('is-paused', !!paused); 257 } 258 259 // the bar running out is the only thing that advances a slide 260 progress.addEventListener('animationend', function (event) { 261 if (event.target.classList.contains('is-running')) { 262 // a natural completion, as opposed to a reader skipping ahead 263 // with a tap or arrow key â those call next() directly 264 track('slide_complete', { 265 story_title: (templates[storyIndex] && templates[storyIndex].dataset.storyTitle) || '', 266 slide_index: slideIndex 267 }); 268 next(); 269 } 270 }); 271 272 // ----------------------------------------------------------- navigation 273 274 function next() { 275 if (slideIndex + 1 < slides.length) { 276 showSlide(slideIndex + 1); 277 return; 278 } 279 280 nextStory(); 281 } 282 283 function previous() { 284 if (slideIndex > 0) { 285 showSlide(slideIndex - 1); 286 return; 287 } 288 289 previousStory(); 290 } 291 292 function nextStory() { 293 if (storyIndex + 1 < templates.length) { 294 openStory(storyIndex + 1, 0); 295 return; 296 } 297 298 close(); 299 } 300 301 function previousStory() { 302 if (storyIndex > 0) { 303 openStory(storyIndex - 1, 0); 304 return; 305 } 306 307 showSlide(0); 308 } 309 310 viewer.querySelector('[data-story-next]').addEventListener('click', next); 311 viewer.querySelector('[data-story-prev]').addEventListener('click', previous); 312 nextStoryButton.addEventListener('click', nextStory); 313 prevStoryButton.addEventListener('click', previousStory); 314 315 viewer.querySelectorAll('[data-story-close]').forEach(function (element) { 316 element.addEventListener('click', function () { 317 close(); 318 }); 319 }); 320 321 // ------------------------------------------------------------- gestures 322 323 frame.addEventListener('pointerdown', function (event) { 324 pointer = { x: event.clientX, y: event.clientY, held: false }; 325 window.clearTimeout(holdTimer); 326 holdTimer = window.setTimeout(function () { 327 if (pointer) { 328 pointer.held = true; 329 setPaused(true); 330 } 331 }, HOLD_MS); 332 }); 333 334 frame.addEventListener('pointerup', function (event) { 335 window.clearTimeout(holdTimer); 336 337 if (!pointer) { 338 return; 339 } 340 341 var dx = event.clientX - pointer.x; 342 var dy = event.clientY - pointer.y; 343 var held = pointer.held; 344 345 pointer = null; 346 setPaused(false); 347 348 if (held) { 349 // holding is pausing, not tapping 350 suppressClick = true; 351 return; 352 } 353 354 if (Math.abs(dx) > SWIPE_X && Math.abs(dx) > Math.abs(dy)) { 355 suppressClick = true; 356 if (dx < 0) { 357 nextStory(); 358 } else { 359 previousStory(); 360 } 361 return; 362 } 363 364 if (dy < -SWIPE_UP && Math.abs(dy) > Math.abs(dx)) { 365 suppressClick = true; 366 if (!cta.hidden && cta.href) { 367 window.location.href = cta.href; 368 } 369 } 370 }); 371 372 frame.addEventListener('pointercancel', function () { 373 window.clearTimeout(holdTimer); 374 pointer = null; 375 setPaused(false); 376 }); 377 378 // capture, so a gesture never also counts as a tap on the zone below it 379 frame.addEventListener('click', function (event) { 380 if (suppressClick) { 381 suppressClick = false;
382 event.preventDefault(); 383 event.stopPropagation(); 384 } 385 }, true); 386 387 // ------------------------------------------------------------- keyboard 388 389 // escape and the focus trap belong to the dialog; only the two steps 390 // through the story are ours 391 viewer.addEventListener('keydown', function (event) { 392 if (event.key === 'ArrowRight') { 393 next(); 394 } else if (event.key === 'ArrowLeft') { 395 previous(); 396 } 397 }); 398 399 window.addEventListener('popstate', function () { 400 pushedState = false; 401 close(); 402 }); 403 404 // The editorial preview renders one story and asks for it to be played 405 // straight away â there is nothing else on that page to click. 406 var openOnLoad = block.dataset.storyOpenOnLoad; 407 408 if (openOnLoad) { 409 var target = covers.filter(function (cover) { 410 return cover.dataset.storyOpen === openOnLoad; 411 })[0]; 412 413 if (target) { 414 open(Number(openOnLoad), target); 415 } 416 } 417 } 418 419 function init() { 420 document.querySelectorAll('.stories-block').forEach(setUpBlock); 421 } 422 423 if (document.readyState === 'loading') { 424 document.addEventListener('DOMContentLoaded', init); 425 } else { 426 init(); 427 } 428})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.