| 1 | /**
|
|---|
| 2 | * De leesweergave: tikken op een bericht opent dat bericht, en een knop om terug
|
|---|
| 3 | * naar boven te gaan.
|
|---|
| 4 | *
|
|---|
| 5 | * Dit was een heel scherm met een eigen route, dat zijn buren zelf ophaalde, de
|
|---|
| 6 | * scrollpositie corrigeerde bij invoegen en de balken wegschoof. Dat is allemaal
|
|---|
| 7 | * weg, en dat is winst: Lezen is nu een WEERGAVE van de feed
|
|---|
| 8 | * (body[data-feed-view="reader"]), naast Tijdlijn en Grid. De feed levert de
|
|---|
| 9 | * berichten al, "meer laden" vult al aan, en het snappen naar berichtgrenzen
|
|---|
| 10 | * doet CSS.
|
|---|
| 11 | *
|
|---|
| 12 | * HET SNAPPEN STAAT HIER MET OPZET NIET IN. Ik heb dat een ronde lang wel
|
|---|
| 13 | * geprobeerd -- richting bijhouden, een vangzone uitrekenen, per scroll-event
|
|---|
| 14 | * het snappunt verzetten -- en dat is de verkeerde laag. Robins bezwaar (20-8):
|
|---|
| 15 | * "scroll-snap op het element is iets anders dan touch events,
|
|---|
| 16 | * requestAnimationFrame etc. dan gaan we te veel van de view doen." Klopt, en
|
|---|
| 17 | * het vocht ook met de browser: de scroll-events bevatten OOK de bewegingen van
|
|---|
| 18 | * zijn eigen snap-animatie, dus de richting die je eruit afleidt is niet die van
|
|---|
| 19 | * de gebruiker.
|
|---|
| 20 | *
|
|---|
| 21 | * Wat er nodig was, was een regel minder in de CSS en niet honderd erbij hier:
|
|---|
| 22 | * zie style.css bij .feed-reader .read-post.
|
|---|
| 23 | *
|
|---|
| 24 | * De titel en de voetlink in read-article.ejs zijn echte <a>'s en doen het werk
|
|---|
| 25 | * voor toetsenbord en schermlezer; de tik hieronder is er voor een duim.
|
|---|
| 26 | *
|
|---|
| 27 | * Vier uitzonderingen op die tik, want een tik die je niet bedoelde is erger dan
|
|---|
| 28 | * geen tik: iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen
|
|---|
| 29 | * werking, een geselecteerde tekst is geen tik, een verschoven vinger is
|
|---|
| 30 | * scrollen, en cmd/ctrl-klik hoort de browser zelf af te handelen.
|
|---|
| 31 | */
|
|---|
| 32 |
|
|---|
| 33 | /**
|
|---|
| 34 | * Terug naar boven, en bewust NIET window.scrollTo(0).
|
|---|
| 35 | *
|
|---|
| 36 | * In een stroom wil je terug naar het BEGIN VAN DIT BERICHT als je halverwege een
|
|---|
| 37 | * lang stuk zit, en pas daarna naar de kop van de pagina. Twee keer drukken doet
|
|---|
| 38 | * dus twee verschillende dingen -- dat scheelt op mobiel een hoop vegen.
|
|---|
| 39 | */
|
|---|
| 40 | function naarBoven() {
|
|---|
| 41 | const zacht = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|---|
| 42 | const gedrag = zacht ? 'smooth' : 'auto';
|
|---|
| 43 | const posts = [...document.querySelectorAll('.feed-reader .read-post')];
|
|---|
| 44 | const huidig = posts.find((a) => {
|
|---|
| 45 | const r = a.getBoundingClientRect();
|
|---|
| 46 | return r.top <= 8 && r.bottom > 8;
|
|---|
| 47 | });
|
|---|
| 48 | // Sta je al bovenaan dit bericht (of bij het eerste), dan naar de paginakop.
|
|---|
| 49 | if (huidig && huidig.getBoundingClientRect().top < -8) {
|
|---|
| 50 | huidig.scrollIntoView({ behavior: gedrag, block: 'start' });
|
|---|
| 51 | return;
|
|---|
| 52 | }
|
|---|
| 53 | window.scrollTo({ top: 0, behavior: gedrag });
|
|---|
| 54 | }
|
|---|
| 55 |
|
|---|
| 56 | /** De knop verschijnt pas als er iets ONDER je ligt om naar terug te keren. */
|
|---|
| 57 | function toonKnop(knop) {
|
|---|
| 58 | knop.classList.toggle('is-zichtbaar', window.scrollY > window.innerHeight * 0.6);
|
|---|
| 59 | }
|
|---|
| 60 |
|
|---|
| 61 | let tapX = 0, tapY = 0;
|
|---|
| 62 | function onPointerDown(e) { tapX = e.clientX; tapY = e.clientY; }
|
|---|
| 63 |
|
|---|
| 64 | function onTap(e) {
|
|---|
| 65 | if (e.defaultPrevented || e.button !== 0) return;
|
|---|
| 66 | if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|---|
| 67 | const t = e.target;
|
|---|
| 68 | if (!t || typeof t.closest !== 'function') return;
|
|---|
| 69 | const art = t.closest('.read-post');
|
|---|
| 70 | if (!art) return;
|
|---|
| 71 | if (t.closest('a, button, input, textarea, select, label, summary, [role="button"]')) return;
|
|---|
| 72 | if (Math.abs(e.clientX - tapX) > 10 || Math.abs(e.clientY - tapY) > 10) return;
|
|---|
| 73 | const sel = window.getSelection && window.getSelection();
|
|---|
| 74 | if (sel && String(sel).trim()) return;
|
|---|
| 75 | const slug = art.dataset.slug;
|
|---|
| 76 | if (!slug) return;
|
|---|
| 77 | location.href = (art.dataset.base || '') + '/' + encodeURIComponent(slug);
|
|---|
| 78 | }
|
|---|
| 79 |
|
|---|
| 80 | /**
|
|---|
| 81 | * Omhoog scrollen blijft VRIJ (Robin, 20-8).
|
|---|
| 82 | *
|
|---|
| 83 | * Snappen hoort bij doorlezen; ga je terug, dan zoek je iets en bepaal je zelf
|
|---|
| 84 | * waar je stopt. CSS kent geen richtingsgevoelig snappen, dus dat ene stukje
|
|---|
| 85 | * moet hier -- maar dan ook niet meer dan dat: we zetten de CSS-functie aan of
|
|---|
| 86 | * uit op de scroller. Geen vangzones, geen snappunten per bericht, geen
|
|---|
| 87 | * scrollpositie-boekhouding.
|
|---|
| 88 | *
|
|---|
| 89 | * DE RICHTING KOMT UIT DE INVOER, niet uit scroll-events. Gemeten op dev: die
|
|---|
| 90 | * events bevatten ook de bewegingen van de browser zelf -- zijn snap-animatie en
|
|---|
| 91 | * de rubber-band -- en die gaan soms omhoog. Wie daaruit de richting afleidt,
|
|---|
| 92 | * leest de browser en niet de gebruiker.
|
|---|
| 93 | *
|
|---|
| 94 | * EN ER WORDT ALLEEN GESCHAKELD BIJ STILSTAND. Dat is de tweede les, en die
|
|---|
| 95 | * kostte een ronde. Eerst zette dit de schakelaar om bij ELK wiel-event en ELKE
|
|---|
| 96 | * vingerbeweging, dus binnen een veeg klapte hij meerdere keren heen en weer.
|
|---|
| 97 | * De browser raadpleegt scroll-snap-type alleen aan het eind van een gebaar of
|
|---|
| 98 | * van de uitloop, en of je daar net vóór of net ná zit bepaalt dan of er
|
|---|
| 99 | * gesnapt wordt. Gemeten (Robins melding "soms triggert het terwijl we nog aan
|
|---|
| 100 | * het doorscrollen zijn"):
|
|---|
| 101 | *
|
|---|
| 102 | * snappen UIT, beweging naar 1459 loopt
|
|---|
| 103 | * -> halverwege snappen AAN gezet
|
|---|
| 104 | * -> eindigt op 1459, NIET op een berichtgrens
|
|---|
| 105 | *
|
|---|
| 106 | * Bij stilstand omzetten is wel onschuldig: dezelfde proef gaf 0px sprong.
|
|---|
| 107 | * Vandaar: richting bepalen bij het BEGIN van een gebaar, en daarna niets meer
|
|---|
| 108 | * aanraken tot de scroll echt stil is.
|
|---|
| 109 | *
|
|---|
| 110 | * Wat je daarvoor inlevert: binnen een veeg ligt de stand vast. Draai je
|
|---|
| 111 | * halverwege om zonder los te laten, dan geldt de stand van dat gebaar nog. Een
|
|---|
| 112 | * besluit per gebaar is voorspelbaar; het omklappen halverwege was het probleem.
|
|---|
| 113 | */
|
|---|
| 114 | const RUST_MS = 120;
|
|---|
| 115 |
|
|---|
| 116 | let bezig = false; // loopt er een gebaar of een uitloop?
|
|---|
| 117 | let rustTimer = null;
|
|---|
| 118 |
|
|---|
| 119 | function zetSnappen(aan) {
|
|---|
| 120 | const el = document.documentElement;
|
|---|
| 121 | const wil = aan ? '' : 'none';
|
|---|
| 122 | if (el.style.scrollSnapType !== wil) el.style.scrollSnapType = wil;
|
|---|
| 123 | }
|
|---|
| 124 |
|
|---|
| 125 | /** Een richting geldt alleen als er NIETS beweegt. Anders negeren we hem. */
|
|---|
| 126 | function nieuwGebaar(naarBeneden) {
|
|---|
| 127 | if (bezig) return;
|
|---|
| 128 | bezig = true;
|
|---|
| 129 | zetSnappen(naarBeneden);
|
|---|
| 130 | }
|
|---|
| 131 |
|
|---|
| 132 | /**
|
|---|
| 133 | * Het gebaar is pas voorbij als de SCROLL stil is, niet als de vinger loslaat:
|
|---|
| 134 | * op iOS loopt de uitloop daarna nog door. `scrollend` zegt dat precies, maar
|
|---|
| 135 | * bestaat niet overal (Chrome 114+, Safari 17+) -- vandaar ook de timer.
|
|---|
| 136 | */
|
|---|
| 137 | function rustNu() { bezig = false; }
|
|---|
| 138 | function planRust() {
|
|---|
| 139 | clearTimeout(rustTimer);
|
|---|
| 140 | rustTimer = setTimeout(rustNu, RUST_MS);
|
|---|
| 141 | }
|
|---|
| 142 |
|
|---|
| 143 | function opWiel(e) { if (Math.abs(e.deltaY) > 1) nieuwGebaar(e.deltaY > 0); }
|
|---|
| 144 | let raakY = 0;
|
|---|
| 145 | function opRaakStart(e) { if (e.touches && e.touches[0]) raakY = e.touches[0].clientY; }
|
|---|
| 146 | function opRaakBeweeg(e) {
|
|---|
| 147 | if (!e.touches || !e.touches[0]) return;
|
|---|
| 148 | const y = e.touches[0].clientY;
|
|---|
| 149 | // Vinger omhoog = inhoud omlaag. Drie pixels speling tegen de trilling van een
|
|---|
| 150 | // duim die stilstaat.
|
|---|
| 151 | if (Math.abs(y - raakY) > 3) { nieuwGebaar(y < raakY); raakY = y; }
|
|---|
| 152 | }
|
|---|
| 153 | function opToets(e) {
|
|---|
| 154 | if (['ArrowDown', 'PageDown', 'End', ' ', 'Spacebar'].indexOf(e.key) >= 0) nieuwGebaar(true);
|
|---|
| 155 | else if (['ArrowUp', 'PageUp', 'Home'].indexOf(e.key) >= 0) nieuwGebaar(false);
|
|---|
| 156 | }
|
|---|
| 157 |
|
|---|
| 158 | let knop = null;
|
|---|
| 159 | let opScroll = null;
|
|---|
| 160 |
|
|---|
| 161 | export function init() {
|
|---|
| 162 | const s = document.getElementById('read-stream');
|
|---|
| 163 | if (!s) return;
|
|---|
| 164 | // Op de stroom, niet per artikel: wat "meer laden" erbij zet doet vanzelf mee.
|
|---|
| 165 | // init() draait bij ELKE paginawissel, dus eerst losmaken -- anders stapelt
|
|---|
| 166 | // dezelfde afhandelaar zich op en vuurt hij twee keer.
|
|---|
| 167 | s.removeEventListener('pointerdown', onPointerDown);
|
|---|
| 168 | s.removeEventListener('click', onTap);
|
|---|
| 169 | s.addEventListener('pointerdown', onPointerDown, { passive: true });
|
|---|
| 170 | s.addEventListener('click', onTap);
|
|---|
| 171 |
|
|---|
| 172 | // De knop staat in de HTML, zodat hij er ook is zonder deze module -- dan doet
|
|---|
| 173 | // hij niets, maar hij springt niet in beeld bij het laden.
|
|---|
| 174 | knop = document.getElementById('read-top');
|
|---|
| 175 | if (knop) {
|
|---|
| 176 | knop.onclick = naarBoven;
|
|---|
| 177 | toonKnop(knop);
|
|---|
| 178 | }
|
|---|
| 179 |
|
|---|
| 180 | if (opScroll) {
|
|---|
| 181 | window.removeEventListener('scroll', opScroll);
|
|---|
| 182 | window.removeEventListener('scrollend', rustNu);
|
|---|
| 183 | window.removeEventListener('resize', opScroll);
|
|---|
| 184 | window.removeEventListener('wheel', opWiel);
|
|---|
| 185 | window.removeEventListener('touchstart', opRaakStart);
|
|---|
| 186 | window.removeEventListener('touchmove', opRaakBeweeg);
|
|---|
| 187 | window.removeEventListener('keydown', opToets);
|
|---|
| 188 | }
|
|---|
| 189 | // Elke scroll -- van een vinger of van de browser zelf -- houdt het gebaar
|
|---|
| 190 | // levend; pas als het stil blijft mag een nieuwe richting gelden.
|
|---|
| 191 | opScroll = () => { if (knop) toonKnop(knop); planRust(); };
|
|---|
| 192 | window.addEventListener('scroll', opScroll, { passive: true });
|
|---|
| 193 | window.addEventListener('scrollend', rustNu);
|
|---|
| 194 | window.addEventListener('resize', opScroll, { passive: true });
|
|---|
| 195 | window.addEventListener('wheel', opWiel, { passive: true });
|
|---|
| 196 | window.addEventListener('touchstart', opRaakStart, { passive: true });
|
|---|
| 197 | window.addEventListener('touchmove', opRaakBeweeg, { passive: true });
|
|---|
| 198 | window.addEventListener('keydown', opToets);
|
|---|
| 199 | }
|
|---|
| 200 |
|
|---|
| 201 | export default { init };
|
|---|