PageSourceSearch

https://www.hooforia.com/content/plugins/jm-stories/assets/jm-stories.js?ver=1790688078

js hooforia.com collected 2026-10-02 18:54:08 UTC 11,887 bytes, 428 lines download raw bytes

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.