| 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 | /**
|
|---|
| 169 | * Tikken op een bericht opent dat bericht.
|
|---|
| 170 | *
|
|---|
| 171 | * De stroom toont de tekst; het gesprek -- reacties, waarderingen, boosts --
|
|---|
| 172 | * staat op de berichtpagina. De titel en de voetlink zijn echte <a>'s en doen
|
|---|
| 173 | * het werk voor toetsenbord en schermlezer; dit hier is het gemak voor een duim.
|
|---|
| 174 | *
|
|---|
| 175 | * Daarom vier uitzonderingen, want een tik die je niet bedoelde is erger dan
|
|---|
| 176 | * geen tik: iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen
|
|---|
| 177 | * werking, een geselecteerde tekst is geen tik, een verschoven vinger is
|
|---|
| 178 | * scrollen, en cmd/ctrl-klik hoort de browser zelf af te handelen.
|
|---|
| 179 | */
|
|---|
| 180 | let tapX = 0, tapY = 0;
|
|---|
| 181 | function onPointerDown(e) { tapX = e.clientX; tapY = e.clientY; }
|
|---|
| 182 |
|
|---|
| 183 | function onTap(e) {
|
|---|
| 184 | if (e.defaultPrevented || e.button !== 0) return;
|
|---|
| 185 | if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|---|
| 186 | const t = e.target;
|
|---|
| 187 | if (!t || typeof t.closest !== 'function') return;
|
|---|
| 188 | const art = t.closest('.read-post');
|
|---|
| 189 | if (!art) return;
|
|---|
| 190 | if (t.closest('a, button, input, textarea, select, label, summary, [role="button"]')) return;
|
|---|
| 191 | if (Math.abs(e.clientX - tapX) > 10 || Math.abs(e.clientY - tapY) > 10) return;
|
|---|
| 192 | const sel = window.getSelection && window.getSelection();
|
|---|
| 193 | if (sel && String(sel).trim()) return;
|
|---|
| 194 | const slug = art.dataset.slug;
|
|---|
| 195 | if (!slug) return;
|
|---|
| 196 | location.href = (art.dataset.base || '') + '/' + encodeURIComponent(slug);
|
|---|
| 197 | }
|
|---|
| 198 |
|
|---|
| 199 | function onScroll() {
|
|---|
| 200 | const y = window.scrollY || 0;
|
|---|
| 201 | if (y < 8) toggleChrome(false);
|
|---|
| 202 | else if (y > lastY + 4) toggleChrome(true);
|
|---|
| 203 | else if (y < lastY - 24) toggleChrome(false);
|
|---|
| 204 | lastY = y;
|
|---|
| 205 | syncAddress();
|
|---|
| 206 | }
|
|---|
| 207 |
|
|---|
| 208 | export function init() {
|
|---|
| 209 | const s = stream();
|
|---|
| 210 | if (!s) return;
|
|---|
| 211 | try { strings = JSON.parse(s.getAttribute('data-i18n') || '{}'); } catch (e) { strings = {}; }
|
|---|
| 212 | s.querySelectorAll('.read-post').forEach((p) => loaded.add(p.dataset.slug));
|
|---|
| 213 | lastY = window.scrollY || 0;
|
|---|
| 214 | window.addEventListener('scroll', onScroll, { passive: true });
|
|---|
| 215 | window.addEventListener('resize', watch, { passive: true });
|
|---|
| 216 | // Op de stroom, niet per artikel: aangevulde berichten doen zo vanzelf mee.
|
|---|
| 217 | s.addEventListener('pointerdown', onPointerDown, { passive: true });
|
|---|
| 218 | s.addEventListener('click', onTap);
|
|---|
| 219 | watch();
|
|---|
| 220 | }
|
|---|
| 221 |
|
|---|
| 222 | export default { init };
|
|---|