| 1 | /**
|
|---|
| 2 | * De leesweergave: een stroom van hele berichten, één per beeld.
|
|---|
| 3 | *
|
|---|
| 4 | * NIET de truc van bartoverkamp.nl. Daar springt de pagina bij de rand naar de
|
|---|
| 5 | * andere kant en wordt het nieuwe artikel "vloeiend" in beeld gezet -- een naad
|
|---|
| 6 | * die verborgen wordt. Hier groeit de stroom in de richting waarin je leest:
|
|---|
| 7 | * bij de onderrand komt de oudere buur eronder te staan, bij de bovenrand de
|
|---|
| 8 | * nieuwere erboven. De browser snapt naar de berichtgrenzen (CSS, proximity).
|
|---|
| 9 | * Er is dan geen naad, dus ook niets te verbergen.
|
|---|
| 10 | *
|
|---|
| 11 | * Elk artikel draagt zijn eigen buren in data-attributen. Wat er nog te halen
|
|---|
| 12 | * valt staat dus altijd op het stuk dat er al is, en deze module hoeft geen
|
|---|
| 13 | * lijst bij te houden die uit de pas kan lopen.
|
|---|
| 14 | *
|
|---|
| 15 | * De namen hieronder zijn Engels, zoals overal in assets/js/mod. Het commentaar
|
|---|
| 16 | * blijft Nederlands, zoals overal in deze repo.
|
|---|
| 17 | */
|
|---|
| 18 |
|
|---|
| 19 | // Hoeveel schermen vooruit we alvast halen. Eén is genoeg: je leest niet
|
|---|
| 20 | // sneller dan je scrolt, en meer betekent alleen meer verkeer.
|
|---|
| 21 | const ROOT_MARGIN = '1px 0px 100% 0px';
|
|---|
| 22 |
|
|---|
| 23 | // De teksten die dit script plaatst. Ze staan in data-i18n op de stroom, want
|
|---|
| 24 | // een zin die hier staat, staat in één taal -- en dat is nooit de taal van de
|
|---|
| 25 | // lezer. Zelfde afspraak als de topnav (mod/chrome.js).
|
|---|
| 26 | let strings = {};
|
|---|
| 27 | function str(key) { return strings[key] || ''; }
|
|---|
| 28 |
|
|---|
| 29 | const stream = () => document.getElementById('read-stream');
|
|---|
| 30 | const loaded = new Set(); // slugs die al in de stroom staan
|
|---|
| 31 | let busyBelow = false;
|
|---|
| 32 | let busyAbove = false;
|
|---|
| 33 | let observer = null;
|
|---|
| 34 | let lastY = 0;
|
|---|
| 35 |
|
|---|
| 36 | // Niet `chrome`: dat is in deze browser ook een global, en een module-scope die
|
|---|
| 37 | // window.chrome overschaduwt is een val voor de volgende lezer.
|
|---|
| 38 | function toggleChrome(hide) {
|
|---|
| 39 | document.body.classList.toggle('read-chrome-hidden', !!hide);
|
|---|
| 40 | }
|
|---|
| 41 |
|
|---|
| 42 | /** Het artikel dat nu het meest in beeld is; die bepaalt titel en URL. */
|
|---|
| 43 | function inView() {
|
|---|
| 44 | const posts = [...document.querySelectorAll('.read-post')];
|
|---|
| 45 | const middle = window.innerHeight / 2;
|
|---|
| 46 | return posts.find((p) => {
|
|---|
| 47 | const r = p.getBoundingClientRect();
|
|---|
| 48 | return r.top <= middle && r.bottom >= middle;
|
|---|
| 49 | }) || posts[0];
|
|---|
| 50 | }
|
|---|
| 51 |
|
|---|
| 52 | /** Titel en adres volgen wat je leest. replaceState: je bladert, je stapelt niet. */
|
|---|
| 53 | function syncAddress() {
|
|---|
| 54 | const p = inView();
|
|---|
| 55 | if (!p) return;
|
|---|
| 56 | const slug = p.dataset.slug;
|
|---|
| 57 | const base = p.dataset.base || '';
|
|---|
| 58 | if (location.pathname === `${base}/read/${slug}`) return;
|
|---|
| 59 | history.replaceState(null, '', `${base}/read/${slug}`);
|
|---|
| 60 | if (p.dataset.title) document.title = p.dataset.title;
|
|---|
| 61 | }
|
|---|
| 62 |
|
|---|
| 63 | async function fetchArticle(slug, base) {
|
|---|
| 64 | const r = await fetch(`${base}/read/${encodeURIComponent(slug)}?partial=1`, {
|
|---|
| 65 | headers: { 'HX-Request': 'true' },
|
|---|
| 66 | credentials: 'same-origin',
|
|---|
| 67 | });
|
|---|
| 68 | if (!r.ok) throw new Error('HTTP ' + r.status);
|
|---|
| 69 | const box = document.createElement('div');
|
|---|
| 70 | box.innerHTML = await r.text();
|
|---|
| 71 | const art = box.querySelector('.read-post');
|
|---|
| 72 | if (!art) throw new Error('no post in the response');
|
|---|
| 73 | return art;
|
|---|
| 74 | }
|
|---|
| 75 |
|
|---|
| 76 | /** Onderaan aanvullen: gewoon erbij zetten, de scrollpositie verandert niet. */
|
|---|
| 77 | async function appendOlder() {
|
|---|
| 78 | const s = stream();
|
|---|
| 79 | if (!s || busyBelow) return;
|
|---|
| 80 | const last = s.querySelector('.read-post:last-of-type');
|
|---|
| 81 | const slug = last && last.dataset.older;
|
|---|
| 82 | if (!slug) return markEnd(s);
|
|---|
| 83 | if (loaded.has(slug)) return;
|
|---|
| 84 | busyBelow = true;
|
|---|
| 85 | const pending = notice(s, 'read-loading', '…');
|
|---|
| 86 | try {
|
|---|
| 87 | const art = await fetchArticle(slug, last.dataset.base || '');
|
|---|
| 88 | loaded.add(slug);
|
|---|
| 89 | pending.remove();
|
|---|
| 90 | s.appendChild(art);
|
|---|
| 91 | watch();
|
|---|
| 92 | } catch (e) {
|
|---|
| 93 | pending.textContent = str('load_error');
|
|---|
| 94 | console.warn('[read] below:', e && e.message);
|
|---|
| 95 | } finally { busyBelow = false; }
|
|---|
| 96 | }
|
|---|
| 97 |
|
|---|
| 98 | /**
|
|---|
| 99 | * Bovenaan aanvullen. Dit is het enige plek waar we de scrollpositie WEL
|
|---|
| 100 | * moeten bijstellen: er komt hoogte bij bóven het beeld, en zonder correctie
|
|---|
| 101 | * schuift alles wat je las naar beneden weg. We meten de hoogte na het invoegen
|
|---|
| 102 | * en tellen die bij de scrollpositie op -- geen animatie, geen sprong.
|
|---|
| 103 | */
|
|---|
| 104 | async function prependNewer() {
|
|---|
| 105 | const s = stream();
|
|---|
| 106 | if (!s || busyAbove) return;
|
|---|
| 107 | const first = s.querySelector('.read-post');
|
|---|
| 108 | const slug = first && first.dataset.newer;
|
|---|
| 109 | if (!slug || loaded.has(slug)) return;
|
|---|
| 110 | busyAbove = true;
|
|---|
| 111 | const root = document.documentElement;
|
|---|
| 112 | try {
|
|---|
| 113 | const art = await fetchArticle(slug, first.dataset.base || '');
|
|---|
| 114 | loaded.add(slug);
|
|---|
| 115 | // Snappen even UIT. De correctie hieronder zet de scrollpositie precies
|
|---|
| 116 | // goed, maar een proximity-snap trekt hem daarna alsnog naar de grens van
|
|---|
| 117 | // het bericht dat er net bij kwam -- gemeten: een sprong ter grootte van
|
|---|
| 118 | // dat hele bericht, precies de ruk die deze opzet moet vermijden.
|
|---|
| 119 | root.style.scrollSnapType = 'none';
|
|---|
| 120 | const before = s.scrollHeight;
|
|---|
| 121 | s.insertBefore(art, first);
|
|---|
| 122 | const added = s.scrollHeight - before;
|
|---|
| 123 | window.scrollBy(0, added);
|
|---|
| 124 | watch();
|
|---|
| 125 | // Twee frames: één om de correctie te laten landen, één om te snappen pas
|
|---|
| 126 | // weer toe te staan als de browser klaar is met deze scroll.
|
|---|
| 127 | requestAnimationFrame(() => requestAnimationFrame(() => { root.style.scrollSnapType = ''; }));
|
|---|
| 128 | } catch (e) {
|
|---|
| 129 | root.style.scrollSnapType = '';
|
|---|
| 130 | console.warn('[read] above:', e && e.message);
|
|---|
| 131 | } finally { busyAbove = false; }
|
|---|
| 132 | }
|
|---|
| 133 |
|
|---|
| 134 | function notice(s, className, text) {
|
|---|
| 135 | const d = document.createElement('div');
|
|---|
| 136 | d.className = className;
|
|---|
| 137 | d.textContent = text;
|
|---|
| 138 | s.appendChild(d);
|
|---|
| 139 | return d;
|
|---|
| 140 | }
|
|---|
| 141 |
|
|---|
| 142 | function markEnd(s) {
|
|---|
| 143 | if (s.querySelector('.read-end')) return;
|
|---|
| 144 | notice(s, 'read-end', str('end'));
|
|---|
| 145 | }
|
|---|
| 146 |
|
|---|
| 147 | /** (Her)richt de waarnemer op de huidige eerste en laatste post. */
|
|---|
| 148 | function watch() {
|
|---|
| 149 | if (observer) observer.disconnect();
|
|---|
| 150 | observer = new IntersectionObserver((entries) => {
|
|---|
| 151 | for (const e of entries) {
|
|---|
| 152 | if (!e.isIntersecting) continue;
|
|---|
| 153 | // GEEN else-if. Bij het laden staat er één artikel in de stroom, en dat is
|
|---|
| 154 | // tegelijk het eerste én het laatste. Met een else-if wint de eerste tak
|
|---|
| 155 | // altijd, en dan draait prependNewer() nooit: wie op het OUDSTE bericht
|
|---|
| 156 | // binnenkomt, krijgt "einde archief" te zien terwijl er zes nieuwere
|
|---|
| 157 | // boven hem staan die hij nooit te pakken krijgt.
|
|---|
| 158 | if (e.target.matches('.read-post:last-of-type')) appendOlder();
|
|---|
| 159 | if (e.target.matches('.read-post:first-of-type')) prependNewer();
|
|---|
| 160 | }
|
|---|
| 161 | }, { rootMargin: ROOT_MARGIN });
|
|---|
| 162 | const first = stream() && stream().querySelector('.read-post');
|
|---|
| 163 | const last = stream() && stream().querySelector('.read-post:last-of-type');
|
|---|
| 164 | if (first) observer.observe(first);
|
|---|
| 165 | if (last && last !== first) observer.observe(last);
|
|---|
| 166 | }
|
|---|
| 167 |
|
|---|
| 168 | function onScroll() {
|
|---|
| 169 | const y = window.scrollY || 0;
|
|---|
| 170 | if (y < 8) toggleChrome(false);
|
|---|
| 171 | else if (y > lastY + 4) toggleChrome(true);
|
|---|
| 172 | else if (y < lastY - 24) toggleChrome(false);
|
|---|
| 173 | lastY = y;
|
|---|
| 174 | syncAddress();
|
|---|
| 175 | }
|
|---|
| 176 |
|
|---|
| 177 | export function init() {
|
|---|
| 178 | const s = stream();
|
|---|
| 179 | if (!s) return;
|
|---|
| 180 | try { strings = JSON.parse(s.getAttribute('data-i18n') || '{}'); } catch (e) { strings = {}; }
|
|---|
| 181 | s.querySelectorAll('.read-post').forEach((p) => loaded.add(p.dataset.slug));
|
|---|
| 182 | lastY = window.scrollY || 0;
|
|---|
| 183 | window.addEventListener('scroll', onScroll, { passive: true });
|
|---|
| 184 | window.addEventListener('resize', watch, { passive: true });
|
|---|
| 185 | watch();
|
|---|
| 186 | }
|
|---|
| 187 |
|
|---|
| 188 | export default { init };
|
|---|