| [b67edbd] | 1 | /**
|
|---|
| [7cf8144] | 2 | * De leesweergave: tikken op een bericht opent dat bericht.
|
|---|
| [b67edbd] | 3 | *
|
|---|
| [7cf8144] | 4 | * Dit was een heel scherm met een eigen route, dat zijn buren zelf ophaalde, de
|
|---|
| 5 | * scrollpositie corrigeerde bij invoegen en de balken wegschoof. Dat is allemaal
|
|---|
| 6 | * weg, en dat is winst: Lezen is nu een DERDE WEERGAVE van de feed
|
|---|
| 7 | * (body[data-feed-view="reader"]), naast Tijdlijn en Grid. De feed levert de
|
|---|
| 8 | * berichten al, "meer laden" vult al aan, en het snappen naar berichtgrenzen
|
|---|
| 9 | * doet CSS. Wat overblijft is dit ene gemak.
|
|---|
| [b67edbd] | 10 | *
|
|---|
| [7cf8144] | 11 | * De titel en de voetlink in read-article.ejs zijn echte <a>'s en doen het werk
|
|---|
| 12 | * voor toetsenbord en schermlezer; dit hier is er voor een duim.
|
|---|
| [100aad8] | 13 | *
|
|---|
| [7cf8144] | 14 | * Vier uitzonderingen, want een tik die je niet bedoelde is erger dan geen tik:
|
|---|
| 15 | * iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen werking,
|
|---|
| 16 | * een geselecteerde tekst is geen tik, een verschoven vinger is scrollen, en
|
|---|
| 17 | * cmd/ctrl-klik hoort de browser zelf af te handelen.
|
|---|
| [b67edbd] | 18 | */
|
|---|
| 19 |
|
|---|
| [e46ebaa] | 20 | /**
|
|---|
| 21 | * Snappen alleen waar het HELPT.
|
|---|
| 22 | *
|
|---|
| 23 | * Elk bericht heeft scroll-snap-align:start en min-height:100svh. Voor een kort
|
|---|
| 24 | * bericht is dat precies goed: het vult het scherm en de volgende klikt netjes
|
|---|
| 25 | * op zijn plek. Voor een LANG bericht is het een val -- je leest naar beneden en
|
|---|
| 26 | * de browser trekt je terug naar de bovenrand, waardoor het scrollen lijkt te
|
|---|
| 27 | * stoppen terwijl je gewoon doorscrollt. Barts melding (20-8).
|
|---|
| 28 | *
|
|---|
| 29 | * "Past dit bericht op het scherm?" is een vraag over gemeten hoogte, en die kan
|
|---|
| 30 | * CSS niet stellen -- vandaar hier. Past het niet, dan gaat de snap eraf en lees
|
|---|
| 31 | * je ononderbroken door tot het volgende bericht wel weer een snappunt is.
|
|---|
| 32 | *
|
|---|
| 33 | * De marge van 4px vangt afrondingsverschillen tussen svh en de echte hoogte;
|
|---|
| 34 | * zonder die speling wipt een bericht dat toevallig exact past heen en weer.
|
|---|
| 35 | */
|
|---|
| 36 | /**
|
|---|
| 37 | * Wanneer is de bovenkant van een bericht een snappunt?
|
|---|
| 38 | *
|
|---|
| 39 | * Alleen zolang je er nog NIET voorbij bent. Zit de bovenrand op of onder de
|
|---|
| 40 | * bovenkant van het scherm, dan kom je er nog aan en mag hij vangen. Is hij
|
|---|
| 41 | * eenmaal voorbij -- je leest in het bericht -- dan gaat de snap eraf.
|
|---|
| 42 | *
|
|---|
| 43 | * Robins formulering (20-8), en die is preciezer dan wat ik er eerst van maakte:
|
|---|
| 44 | * "in de post zelf mag er nooit gesnapt worden, ook niet bovenin; enkel de
|
|---|
| 45 | * onderkant mag snappen naar de volgende post, dus daar de bovenkant van".
|
|---|
| 46 | * Precies dat: de onderkant van bericht N is de bovenkant van N+1, en die is een
|
|---|
| 47 | * doelwit omdat je hem nadert. De bovenkant van het bericht waar je IN zit is
|
|---|
| 48 | * dat niet meer, want daar ben je voorbij.
|
|---|
| 49 | *
|
|---|
| 50 | * Zonder deze regel trok proximity je terug naar de bovenrand zodra je een paar
|
|---|
| 51 | * regels verder scrolde, en leek het of het scrollen vastliep.
|
|---|
| 52 | *
|
|---|
| 53 | * De 4px is speling voor afronding: tijdens het snappen zelf loopt top naar 0 en
|
|---|
| 54 | * mag hij niet halverwege afhaken.
|
|---|
| 55 | */
|
|---|
| 56 | const SPELING = 4;
|
|---|
| 57 |
|
|---|
| 58 | /**
|
|---|
| 59 | * En snappen doet alleen mee op de weg NAAR BENEDEN (Robin, 20-8).
|
|---|
| 60 | *
|
|---|
| 61 | * Omhoog scroll je om iets terug te zoeken, en dan is elke vangst een hindernis:
|
|---|
| 62 | * je wilt zelf bepalen waar je stopt. Omlaag lees je door, en dan helpt de
|
|---|
| 63 | * grens juist. Dus bij omhoog gaat de snap er overal af.
|
|---|
| 64 | *
|
|---|
| 65 | * De drempel van 2px is er tegen richtingsruis: tijdens een vloeiende snap
|
|---|
| 66 | * schommelt scrollY een fractie, en zonder speling zou de richting dan heen en
|
|---|
| 67 | * weer klappen -- precies midden in de beweging die net soepel moest zijn.
|
|---|
| 68 | */
|
|---|
| 69 | let laatsteY = 0;
|
|---|
| 70 | let omlaag = true;
|
|---|
| 71 |
|
|---|
| 72 | /**
|
|---|
| 73 | * Hoe DICHT bij de grens hij vangt: de onderste VANGZONE pixels van een bericht.
|
|---|
| 74 | *
|
|---|
| 75 | * In pixels, en bewust niet inhoudelijk. Ik probeerde het aan de voet van het
|
|---|
| 76 | * bericht te hangen ("ben je de reacties voorbij"), maar bij een KORT bericht
|
|---|
| 77 | * gaat dat mis: min-height rekt zo'n bericht tot een vol scherm, dus de voet
|
|---|
| 78 | * staat middenin met lege ruimte eronder en de zone begint op een willekeurige
|
|---|
| 79 | * plek. Robin ving dat (20-8) voordat het uitgerold stond.
|
|---|
| 80 | *
|
|---|
| 81 | * Eerst stond dit op 20px en dat was te krap om ooit te vangen. Honderdvijftig
|
|---|
| 82 | * is ruwweg het laatste stukje van een bericht: kom je daarbinnen tot stilstand,
|
|---|
| 83 | * dan trekt hij de streep recht naar de bovenkant van het volgende. Stop je
|
|---|
| 84 | * eerder, dan blijf je gewoon staan.
|
|---|
| 85 | *
|
|---|
| 86 | * Eén getal, dus makkelijk bij te stellen als het onder een duim anders voelt.
|
|---|
| 87 | */
|
|---|
| 88 | const VANGZONE = 150;
|
|---|
| 89 |
|
|---|
| 90 | function ijkEen(art) {
|
|---|
| 91 | const top = art.getBoundingClientRect().top;
|
|---|
| 92 | // Omhoog: nooit. Omlaag: alleen een grens die je NADERT en die binnen de zone
|
|---|
| 93 | // ligt. De ondergrens blijft krap (SPELING): eenmaal voorbij wordt er niet
|
|---|
| 94 | // teruggetrokken, ook niet over een paar pixels.
|
|---|
| 95 | const wil = (!omlaag || top < -SPELING || top > VANGZONE) ? 'none' : '';
|
|---|
| 96 | // Alleen aanraken als het echt verandert: elke stijlwijziging tijdens een
|
|---|
| 97 | // vloeiende scroll is een kans op een hik, zeker op iOS.
|
|---|
| 98 | if (art.style.scrollSnapAlign !== wil) art.style.scrollSnapAlign = wil;
|
|---|
| 99 | }
|
|---|
| 100 |
|
|---|
| 101 | function ijkSnappen() {
|
|---|
| 102 | const y = window.scrollY;
|
|---|
| 103 | if (Math.abs(y - laatsteY) > 2) omlaag = y > laatsteY;
|
|---|
| 104 | laatsteY = y;
|
|---|
| 105 | document.querySelectorAll('.feed-reader .read-post').forEach(ijkEen);
|
|---|
| 106 | }
|
|---|
| 107 |
|
|---|
| 108 | /**
|
|---|
| 109 | * Terug naar boven, en bewust NIET window.scrollTo(0).
|
|---|
| 110 | *
|
|---|
| 111 | * In een stroom wil je terug naar het BEGIN VAN DIT BERICHT als je halverwege een
|
|---|
| 112 | * lang stuk zit, en pas daarna naar de kop van de pagina. Twee keer drukken doet
|
|---|
| 113 | * dus twee verschillende dingen -- dat scheelt op mobiel een halve minuut vegen.
|
|---|
| 114 | */
|
|---|
| 115 | function naarBoven() {
|
|---|
| 116 | const zacht = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|---|
| 117 | const gedrag = zacht ? 'smooth' : 'auto';
|
|---|
| 118 | const posts = [...document.querySelectorAll('.feed-reader .read-post')];
|
|---|
| 119 | const huidig = posts.find((a) => {
|
|---|
| 120 | const r = a.getBoundingClientRect();
|
|---|
| 121 | return r.top <= 8 && r.bottom > 8;
|
|---|
| 122 | });
|
|---|
| 123 | // Sta je al bovenaan dit bericht (of bij het eerste), dan naar de paginakop.
|
|---|
| 124 | if (huidig && huidig.getBoundingClientRect().top < -8) {
|
|---|
| 125 | huidig.scrollIntoView({ behavior: gedrag, block: 'start' });
|
|---|
| 126 | return;
|
|---|
| 127 | }
|
|---|
| 128 | window.scrollTo({ top: 0, behavior: gedrag });
|
|---|
| 129 | }
|
|---|
| 130 |
|
|---|
| 131 | /** De knop verschijnt pas als er iets ONDER je ligt om naar terug te keren. */
|
|---|
| 132 | function toonKnop(knop) {
|
|---|
| 133 | knop.classList.toggle('is-zichtbaar', window.scrollY > window.innerHeight * 0.6);
|
|---|
| 134 | }
|
|---|
| 135 |
|
|---|
| [847775c] | 136 | let tapX = 0, tapY = 0;
|
|---|
| 137 | function onPointerDown(e) { tapX = e.clientX; tapY = e.clientY; }
|
|---|
| 138 |
|
|---|
| 139 | function onTap(e) {
|
|---|
| 140 | if (e.defaultPrevented || e.button !== 0) return;
|
|---|
| 141 | if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|---|
| 142 | const t = e.target;
|
|---|
| 143 | if (!t || typeof t.closest !== 'function') return;
|
|---|
| 144 | const art = t.closest('.read-post');
|
|---|
| 145 | if (!art) return;
|
|---|
| 146 | if (t.closest('a, button, input, textarea, select, label, summary, [role="button"]')) return;
|
|---|
| 147 | if (Math.abs(e.clientX - tapX) > 10 || Math.abs(e.clientY - tapY) > 10) return;
|
|---|
| 148 | const sel = window.getSelection && window.getSelection();
|
|---|
| 149 | if (sel && String(sel).trim()) return;
|
|---|
| 150 | const slug = art.dataset.slug;
|
|---|
| 151 | if (!slug) return;
|
|---|
| 152 | location.href = (art.dataset.base || '') + '/' + encodeURIComponent(slug);
|
|---|
| 153 | }
|
|---|
| 154 |
|
|---|
| [e46ebaa] | 155 | let knop = null;
|
|---|
| 156 | let opScroll = null;
|
|---|
| 157 |
|
|---|
| [b67edbd] | 158 | export function init() {
|
|---|
| [7cf8144] | 159 | const s = document.getElementById('read-stream');
|
|---|
| [b67edbd] | 160 | if (!s) return;
|
|---|
| [7cf8144] | 161 | // Op de stroom, niet per artikel: wat "meer laden" erbij zet doet vanzelf mee.
|
|---|
| 162 | // init() draait bij ELKE paginawissel, dus eerst losmaken -- anders stapelt
|
|---|
| 163 | // dezelfde afhandelaar zich op en vuurt hij twee keer.
|
|---|
| 164 | s.removeEventListener('pointerdown', onPointerDown);
|
|---|
| 165 | s.removeEventListener('click', onTap);
|
|---|
| [847775c] | 166 | s.addEventListener('pointerdown', onPointerDown, { passive: true });
|
|---|
| 167 | s.addEventListener('click', onTap);
|
|---|
| [e46ebaa] | 168 |
|
|---|
| 169 | // De knop staat in de HTML (read-top.ejs), zodat hij er ook is zonder deze
|
|---|
| 170 | // module -- dan doet hij niets, maar hij springt niet in beeld bij het laden.
|
|---|
| 171 | knop = document.getElementById('read-top');
|
|---|
| 172 | if (knop) {
|
|---|
| 173 | knop.onclick = naarBoven;
|
|---|
| 174 | toonKnop(knop);
|
|---|
| 175 | }
|
|---|
| 176 |
|
|---|
| 177 | // Eén luisteraar, en losmaken bij een volgende init(): deze module draait bij
|
|---|
| 178 | // ELKE paginawissel en anders stapelen ze op.
|
|---|
| 179 | if (opScroll) {
|
|---|
| 180 | window.removeEventListener('scroll', opScroll);
|
|---|
| 181 | window.removeEventListener('resize', opScroll);
|
|---|
| 182 | }
|
|---|
| 183 | // Eén keer per frame, niet per scroll-event: scroll vuurt tientallen keren per
|
|---|
| 184 | // seconde en we raken hier de stijl van elk bericht aan.
|
|---|
| 185 | let gepland = false;
|
|---|
| 186 | opScroll = () => {
|
|---|
| 187 | if (knop) toonKnop(knop);
|
|---|
| 188 | if (gepland) return;
|
|---|
| 189 | gepland = true;
|
|---|
| 190 | requestAnimationFrame(() => { gepland = false; ijkSnappen(); });
|
|---|
| 191 | };
|
|---|
| 192 | window.addEventListener('scroll', opScroll, { passive: true });
|
|---|
| 193 | window.addEventListener('resize', opScroll, { passive: true });
|
|---|
| 194 | ijkSnappen(); // ook meteen bij binnenkomst, niet pas bij de eerste scroll
|
|---|
| [b67edbd] | 195 | }
|
|---|
| 196 |
|
|---|
| 197 | export default { init };
|
|---|