source: Klonkt/src/assets/js/mod/read.js@ edd5ccc

main
Last change on this file since edd5ccc was edd5ccc, checked in by Robin <roboburr@…>, 2 weeks ago

Lezen op touch: het richten is de snap

Robins regel (24-8): het selecteren van het doel mag de scroll zelf bijsturen.
De gekozen grens krijgt dus geen aparte snap-animatie met een eigen duur meer;
de lopende uitloop krijgt het nieuwe doel, met dezelfde demping
(syncTouchLerp) waarmee hij al onderweg is. Een doorlopende beweging die op de
lijn eindigt, in plaats van een glijvlucht met halverwege een muur.

De beoordeling verschoof daarvoor van 150ms naar een timeout van 0: het
virtual-scroll-event komt binnen voordat lenis de touchend verwerkt, een tik
later staat targetScroll op de echte landing en sturen we meteen bij.

MOD_V naar 58.

Co-Authored-By: Claude Fable 5 <noreply@…>

  • Property mode set to 100644
File size: 31.1 KB
Line 
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// ── Paginamodus ─────────────────────────────────────────────────────────────
34/**
35 * Elk bericht een paneel dat zelf scrollt; tussen berichten ga je met knoppen.
36 *
37 * Robins voorstel (20-8) nadat het namaken van iOS-momentum niet lukte, en dat
38 * is de winst: in dit model DOET het scrollgevoel er niet toe. Er is geen
39 * momentum om na te bouwen en geen snap die vangt.
40 *
41 * Hoe het werkt:
42 * - lenis.stop() zet het scrollen van de STROOM stil. Lenis vangt dan wiel en
43 * vinger af (hij preventDefault't in onVirtualScroll), dus je kunt niet meer
44 * tussen berichten door scrollen.
45 * - data-lenis-prevent op elk paneel houdt de scroll BINNEN een bericht
46 * ongemoeid. Dat werkt ook terwijl Lenis gestopt is, want die controle staat
47 * in zijn bron vóór de gestopt-controle. Nagekeken in de dist, niet gehoopt.
48 * - Bewegen tussen panelen gaat met lenis.scrollTo(..., { force: true }), want
49 * force is precies de uitzondering die een gestopte Lenis toch laat scrollen.
50 *
51 * Geldt waar een bericht een eigen scherm heeft: op mobiel altijd, op desktop
52 * als de site dat instelt (reader_full_page). Buiten die modus verandert er
53 * niets -- dan is Lenis gewoon aan en scrol je vrij.
54 */
55/**
56 * PAGINAMODUS STAAT UIT -- maar de code blijft staan (Robin, 20-8: "weghalen
57 * maar bewaren, voor als ik het later weer wil").
58 *
59 * Wat het is: elk bericht een paneel van een scherm dat zelf scrolt, met twee
60 * balken om ertussen te navigeren en de stroomscroll vastgezet. Gebouwd omdat
61 * het namaken van iOS-momentum niet lukte; in dit model doet het scrollgevoel er
62 * namelijk niet toe. Mobiel is nu terug op systeemscroll met snap, en dat is wat
63 * er ook stond voordat we dit probeerden.
64 *
65 * Aanzetten: deze constante op true. Dan komen de balken terug (CSS hangt aan
66 * body.is-paged), stopt Lenis de stroomscroll en scrollen de panelen zelf.
67 */
68const PAGINAMODUS = false;
69
70function paginaModus() {
71 return PAGINAMODUS && OP_TOUCH.matches && document.body.dataset.feedView === 'reader';
72}
73
74function panelen() {
75 return [...document.querySelectorAll('.feed-reader .read-post')];
76}
77
78/** Welk paneel vult nu het scherm? Het eerste waarvan de bovenkant niet voorbij is. */
79function huidigIndex() {
80 const P = panelen();
81 for (let i = 0; i < P.length; i++) {
82 if (P[i].getBoundingClientRect().top > 8) return Math.max(0, i - 1);
83 }
84 return Math.max(0, P.length - 1);
85}
86
87function gaNaar(i) {
88 const P = panelen();
89 const doel = P[Math.max(0, Math.min(P.length - 1, i))];
90 if (!doel) return;
91 if (lenis) lenis.scrollTo(doel, { force: true });
92 else doel.scrollIntoView({ behavior: 'smooth', block: 'start' });
93 setTimeout(zetBalken, 80);
94}
95
96/**
97 * Omhoog vanaf het EERSTE bericht brengt je naar de header.
98 *
99 * In paginamodus staat Lenis stil, dus de pagina scrolt niet meer met je vinger
100 * -- en dan is alles boven het eerste bericht onbereikbaar. Robin liep daar
101 * tegenaan (20-8): "ik kan niet meer terug scrollen naar de header". De
102 * omhoog-knop is daar de enige weg naartoe, dus die krijgt er een trede bij.
103 */
104function gaOmhoog() {
105 const i = huidigIndex();
106 if (i > 0) { gaNaar(i - 1); return; }
107 if (lenis) lenis.scrollTo(0, { force: true });
108 else window.scrollTo({ top: 0, behavior: 'smooth' });
109}
110
111/**
112 * Welke balk hoort er te staan? Bovenaan de pagina is er niets boven je, dus dan
113 * geen bovenbalk. Buiten paginamodus staan ze allebei niet -- de CSS verbergt ze
114 * daar al, maar hidden houdt ze ook uit de toetsenbordvolgorde.
115 */
116function zetBalken() {
117 const boven = document.getElementById('read-prev');
118 const onder = document.getElementById('read-next-nav');
119 const aan = paginaModus();
120 // De bovenbalk hoort NOOIT over de header te liggen. Hij verscheen al zodra je
121 // een paar pixels scrolde, en dekte dan de avatar, de omschrijving en de
122 // weergaveknoppen af (Robins schermafbeelding, 20-8). Nu komt hij pas als het
123 // eerste bericht de bovenrand van het scherm heeft bereikt -- dan is de header
124 // voorbij en is er ook echt iets om naar terug te gaan.
125 const eerste = panelen()[0];
126 const headerNogInBeeld = eerste ? eerste.getBoundingClientRect().top > 4 : true;
127 if (boven) boven.hidden = !aan || headerNogInBeeld;
128 if (onder) onder.hidden = !aan;
129 if (onder && aan) onder.style.bottom = onderChroom() + 'px';
130}
131
132/**
133 * Hoe hoog staat de onderrand van het scherm werkelijk vol?
134 *
135 * De onderbalk stond op een geraden 4.75rem boven de onderkant, en dan zweeft
136 * hij: soms een kier boven de speler, soms er half achter. De tabbalk en de
137 * mini-speler hebben allebei een eigen hoogte, ze stapelen op mobiel, en de
138 * speler komt en gaat. Dus meten in plaats van gokken: hoe ver ligt de BOVENKANT
139 * van het hoogste vaste element boven de onderrand van het venster?
140 */
141function onderChroom() {
142 // .bottom-tab-fab staat erbij omdat de Write-knop BOVEN de tabbalk uitsteekt:
143 // meet je alleen de balk, dan legt onze balk zich over die knop heen (Robins
144 // schermafbeelding, 20-8).
145 const kandidaten = ['.bottom-tab', '.bottom-tab-fab', '#pcms-audio-player'];
146 let hoogste = 0;
147 kandidaten.forEach((sel) => {
148 const el = document.querySelector(sel);
149 if (!el) return;
150 const st = getComputedStyle(el);
151 if (st.display === 'none' || st.visibility === 'hidden') return;
152 const r = el.getBoundingClientRect();
153 if (r.height <= 0) return;
154 hoogste = Math.max(hoogste, window.innerHeight - r.top);
155 });
156 return Math.round(hoogste);
157}
158
159function pasPaginaModusToe() {
160 const aan = paginaModus();
161 document.body.classList.toggle('is-paged', aan);
162 panelen().forEach((a) => {
163 if (aan) a.setAttribute('data-lenis-prevent', '');
164 else a.removeAttribute('data-lenis-prevent');
165 });
166 if (lenis) { if (aan) lenis.stop(); else lenis.start(); }
167 zetBalken();
168}
169
170/**
171 * Terug naar boven, en bewust NIET window.scrollTo(0).
172 *
173 * In een stroom wil je terug naar het BEGIN VAN DIT BERICHT als je halverwege een
174 * lang stuk zit, en pas daarna naar de kop van de pagina. Twee keer drukken doet
175 * dus twee verschillende dingen -- dat scheelt op mobiel een hoop vegen.
176 */
177function naarBoven() {
178 const zacht = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
179 const gedrag = zacht ? 'smooth' : 'auto';
180 const posts = [...document.querySelectorAll('.feed-reader .read-post')];
181 const huidig = posts.find((a) => {
182 const r = a.getBoundingClientRect();
183 return r.top <= 8 && r.bottom > 8;
184 });
185 // Sta je al bovenaan dit bericht (of bij het eerste), dan naar de paginakop.
186 if (huidig && huidig.getBoundingClientRect().top < -8) {
187 huidig.scrollIntoView({ behavior: gedrag, block: 'start' });
188 return;
189 }
190 window.scrollTo({ top: 0, behavior: gedrag });
191}
192
193/** De knop verschijnt pas als er iets ONDER je ligt om naar terug te keren. */
194function toonKnop(knop) {
195 knop.classList.toggle('is-zichtbaar', window.scrollY > window.innerHeight * 0.6);
196}
197
198let tapX = 0, tapY = 0;
199function onPointerDown(e) { tapX = e.clientX; tapY = e.clientY; }
200
201function onTap(e) {
202 if (e.defaultPrevented || e.button !== 0) return;
203 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
204 const t = e.target;
205 if (!t || typeof t.closest !== 'function') return;
206 const art = t.closest('.read-post');
207 if (!art) return;
208 if (t.closest('a, button, input, textarea, select, label, summary, [role="button"]')) return;
209 if (Math.abs(e.clientX - tapX) > 10 || Math.abs(e.clientY - tapY) > 10) return;
210 const sel = window.getSelection && window.getSelection();
211 if (sel && String(sel).trim()) return;
212
213 const slug = art.dataset.slug;
214 if (!slug) return;
215 location.href = (art.dataset.base || '') + '/' + encodeURIComponent(slug);
216}
217
218/**
219 * Omhoog scrollen blijft VRIJ (Robin, 20-8).
220 *
221 * Snappen hoort bij doorlezen; ga je terug, dan zoek je iets en bepaal je zelf
222 * waar je stopt. CSS kent geen richtingsgevoelig snappen, dus dat ene stukje
223 * moet hier -- maar dan ook niet meer dan dat: we zetten de CSS-functie aan of
224 * uit op de scroller. Geen vangzones, geen snappunten per bericht, geen
225 * scrollpositie-boekhouding.
226 *
227 * DE RICHTING KOMT UIT DE INVOER, niet uit scroll-events. Gemeten op dev: die
228 * events bevatten ook de bewegingen van de browser zelf -- zijn snap-animatie en
229 * de rubber-band -- en die gaan soms omhoog. Wie daaruit de richting afleidt,
230 * leest de browser en niet de gebruiker.
231 *
232 * EN ER WORDT ALLEEN GESCHAKELD BIJ STILSTAND. Dat is de tweede les, en die
233 * kostte een ronde. Eerst zette dit de schakelaar om bij ELK wiel-event en ELKE
234 * vingerbeweging, dus binnen een veeg klapte hij meerdere keren heen en weer.
235 * De browser raadpleegt scroll-snap-type alleen aan het eind van een gebaar of
236 * van de uitloop, en of je daar net vóór of net ná zit bepaalt dan of er
237 * gesnapt wordt. Gemeten (Robins melding "soms triggert het terwijl we nog aan
238 * het doorscrollen zijn"):
239 *
240 * snappen UIT, beweging naar 1459 loopt
241 * -> halverwege snappen AAN gezet
242 * -> eindigt op 1459, NIET op een berichtgrens
243 *
244 * Bij stilstand omzetten is wel onschuldig: dezelfde proef gaf 0px sprong.
245 * Vandaar: richting bepalen bij het BEGIN van een gebaar, en daarna niets meer
246 * aanraken tot de scroll echt stil is.
247 *
248 * Wat je daarvoor inlevert: binnen een veeg ligt de stand vast. Draai je
249 * halverwege om zonder los te laten, dan geldt de stand van dat gebaar nog. Een
250 * besluit per gebaar is voorspelbaar; het omklappen halverwege was het probleem.
251 */
252const RUST_MS = 120;
253
254let bezig = false; // loopt er een gebaar of een uitloop?
255let ooitGescrold = false; // snappen begint UIT, zie zetSnappen()
256let rustTimer = null;
257
258/**
259 * LENIS, en alleen op desktop.
260 *
261 * Op touch doet Lenis van zichzelf niets (syncTouch staat standaard uit) en is
262 * het systeem-scrollen al soepel -- daar blijft de native CSS-snap staan die
263 * hierboven beschreven is. Robins keuze (20-8): "enkel voor desktop, dat is
264 * prima, logisch dat het niet op mobiel kan".
265 *
266 * WAAROM LENIS HIER STAAT, en dat is niet het vloeiende scrollen: de DUUR van
267 * een snap is met native scroll-snap niet in te stellen -- die zit in de browser.
268 * Lenis' snap-pakket wel: duration, easing, distanceThreshold en debounce zijn
269 * allemaal van ons. Dat was de aanleiding.
270 *
271 * Het vloeiende scrollen (smoothWheel) kwam er daarna bij, en dat is de kant die
272 * OPPASSEN vraagt. Een Mac-trackpad heeft zijn EIGEN momentum, en Lenis'
273 * demping komt daar bovenop -- dubbel gedempt voelt drijverig. Vandaar lerp 0.2
274 * in plaats van de standaard 0.1. Robin vond dat op 20-8 nog steeds te zweverig,
275 * dus dat getal is nog niet uit; zie de opmerking bij de instellingen.
276 *
277 * De stand met `smoothWheel: false` werkte ook, en dan doet Lenis alleen de
278 * snap. Dat is de terugvalpositie als het vloeiende scrollen niet bevalt.
279 *
280 * lenis/snap haakt alleen in op lenis.on('scroll') en roept lenis.scrollTo aan
281 * (nagekeken in de dist), dus die opzet werkt.
282 */
283const OP_DESKTOP = window.matchMedia('(hover: hover) and (pointer: fine)');
284// PROEF (20-8): Lenis ook op touch. Robin meldde op mobiel drie dingen -- de
285// header niet zichtbaar bij laden, snappen dat soms te ver gaat, en horizontaal
286// kunnen scrollen. De eerste twee komen van de NATIVE snap: die vangt al bij het
287// laden, en zijn uitloop vliegt bij een flick over een grens heen. Lenis zet de
288// native snap uit en doet het zelf, dus die twee kunnen ermee verdwijnen.
289//
290// De prijs is echt: syncTouch betekent dat Lenis het VINGERSCROLLEN overneemt
291// van iOS -- momentum, rubber-band en het wegschuiven van de adresbalk worden
292// dan een benadering in JavaScript. De makers waarschuwen daar zelf voor op
293// iOS < 16. Dit staat er dus als proef, niet als besluit: voelt het niet goed,
294// dan is OP_TOUCH weer false zetten de hele terugweg.
295const OP_TOUCH = window.matchMedia('(hover: none) and (pointer: coarse)');
296const VENDOR_V = 1; // ophogen als de bestanden in /assets/js/vendor wijzigen
297
298let lenis = null;
299let snap = null;
300
301async function startLenis() {
302 // DESKTOP EN TOUCH ALLEBEI. Er heeft hier een tijd gestaan dat mobiel terug was
303 // op de systeemscroll; dat klopte niet meer met de regel hieronder, die
304 // syncTouch juist AANzet op touch. Reden dat Lenis ook op de telefoon meedraait:
305 // zijn snap heeft zijn scroll-gebeurtenissen nodig om te kunnen timen.
306 if (lenis || !(OP_DESKTOP.matches || OP_TOUCH.matches)) return;
307 const [L, S] = await Promise.all([
308 import(`/assets/vendor/lenis.mjs?v=${VENDOR_V}`),
309 import(`/assets/vendor/lenis-snap.mjs?v=${VENDOR_V}`),
310 ]);
311 lenis = new L.default({
312 /**
313 * Wat Lenis MET RUST LAAT.
314 *
315 * In paginamodus staat Lenis stil en scrolt alleen het paneel van het
316 * bericht zelf. Dat regelde ik eerst met een data-lenis-prevent-attribuut
317 * dat de module op elk paneel zette -- maar dan hangt het scrollen af van of
318 * dat attribuut op tijd en op elk (ook later bijgeladen) paneel staat, en
319 * Robin kon binnen een lang bericht niet scrollen.
320 *
321 * Deze functie stelt dezelfde vraag zonder die afhankelijkheid: is dit een
322 * bericht-paneel? Dan bemoeit Lenis zich er niet mee en scrolt de browser
323 * het zelf -- wat op een telefoon precies is wat je wilt, want dat is de
324 * systeemscroll.
325 */
326 prevent: (node) => !!(node && node.classList
327 && node.classList.contains('read-post')
328 && document.body.classList.contains('is-paged')),
329 // Lenis tekent de scrollbeweging zelf, maar STEVIG GEDEMPT (lerp 0.2 in
330 // plaats van de standaard 0.1). Reden: een muiswiel scrollt in schokken en
331 // heeft die demping nodig; een Mac-trackpad heeft zijn EIGEN momentum en
332 // krijgt er dan een tweede overheen -- dat is precies het drijverige gevoel
333 // waar Robin voor waarschuwde. Hoger betekent korter naijlen, dus dit is de
334 // middenweg: de schokjes weg, de nasleep kort.
335 // Staat het toch te zweven, dan is lerp omhoog (richting 1) of terug naar
336 // smoothWheel:false de knop -- die stand werkte ook, met alleen de snap.
337 smoothWheel: true,
338 lerp: 0.2,
339 // Alleen op touch overneemt hij het vingerscrollen; op desktop hoeft dat niet.
340 syncTouch: OP_TOUCH.matches,
341 // Twee touch-knoppen, en ze doen echt iets anders. Lenis rekent bij het
342 // LOSLATEN eenmalig een doel uit -- afstand = snelheid^touchInertiaExponent
343 // -- en kruipt daar dan naartoe met syncTouchLerp.
344 //
345 // touchInertiaExponent hoe VER de flick draagt (standaard 1.7)
346 // syncTouchLerp hoe hard hij REMT (standaard 0.075)
347 //
348 // Let op: `touchInertiaMultiplier` bestaat NIET in 1.3.26. Lenis slikt
349 // onbekende opties zonder fout, dus zo'n regel lijkt te werken en doet niets.
350 // Controleer een optienaam in src/assets/vendor/lenis.mjs voor je hem zet.
351 //
352 // Robin wilde langer en sneller uitrollen zonder remgevoel, dus de exponent
353 // gaat omhoog (een stevige flick draagt daarmee ruim vier tot zes keer zo
354 // ver) en de lerp gaat juist boven de standaard: hij legt die afstand vlot
355 // af in plaats van er stroperig naartoe te kruipen.
356 ...(OP_TOUCH.matches ? { syncTouchLerp: 0.09, touchInertiaExponent: 2.2 } : {}),
357 autoRaf: true,
358 });
359 snap = new S.default(lenis, {
360 type: 'proximity',
361 // DE VANGZONE, en dit is HET getal om aan te draaien. Ooit 12%, toen 55%
362 // (20-8: eerder vangen, boven de reacties), en 20-8 terug naar 12% -- want
363 // sinds het snappen alleen nog vooruit werkt, voelde die brede zone als
364 // trekken tijdens het lezen. 12% is ~110 pixels op een venster van 910: hij
365 // vangt pas vlak vóór de lijn.
366 // Houd dit gelijk aan vangZone() hieronder: deze drempel bepaalt of
367 // lenis/snap überhaupt aanklopt, die andere of wij het doorlaten. Zet je
368 // deze lager dan die, dan komt onze regel nooit aan bod.
369 distanceThreshold: '12%',
370 // Hoe lang na de laatste scrollbeweging hij mag vangen. De standaard is 500
371 // en dat voelt als te laat. Op touch nog korter dan op desktop, want daar
372 // eindigt een veeg in een lange, trage staart -- en juist dan wil je dat de
373 // snap er snel bij is in plaats van te wachten tot de laatste pixel stil ligt.
374 //
375 // LET OP DE SPANNING: een vloeiendere uitloop (lagere syncTouchLerp) maakt
376 // die staart langer, en stelt de snap dus uit. Deze twee getallen houden
377 // elkaar in evenwicht; draai je aan de een, kijk dan ook naar de ander.
378 // OP TOUCH JUIST LANG, en dat is het omgekeerde van wat ik vorige ronde deed.
379 // Tussen je vinger loslaten en het op gang komen van de uitloop zit een korte
380 // stilte in de scrollbeweging. Met 25ms viel de snap precies in dat gat: hij
381 // greep op het moment van loslaten en knipte de veeg af -- wat aanvoelt als
382 // "stroef bij het loslaten", en het werd er dan ook erger van. 200ms laat de
383 // uitloop eerst zijn werk doen.
384 // Met een iOS-achtige uitloop duurt de staart lang; te kort wachten laat de
385 // snap midden in de vlucht ingrijpen. 150ms laat hem uitrollen en vangt dan.
386 debounce: OP_TOUCH.matches ? 150 : 60,
387 // Op touch korter: een telefoon vraagt om directer antwoord dan een muis, en
388 // de snap komt daar aan het eind van een lange uitloop -- dan mag hij kort.
389 duration: OP_TOUCH.matches ? 0.18 : 0.4,
390 // Vlot weg, dan steeds langzamer aankomen (Robin, 20-8). easeOutQuart: op de
391 // helft van de tijd is 94% van de weg af, en de rest dempt zacht uit.
392 // Bewust NIET Lenis' standaard easeOutExpo -- die schiet weg en kruipt dan
393 // zo lang na dat het lijkt of hij niet afmaakt.
394 easing: (t) => 1 - Math.pow(1 - t, 4),
395 });
396 // Het snappunt is de bovenkant van elk bericht -- ook van het laatste. Dat
397 // deed eerder niet mee omdat zijn bovenkant onbereikbaar was; sinds het
398 // laatste bericht in CSS een volle schermhoogte krijgt, kan hij wel.
399 const berichten = [...document.querySelectorAll('.feed-reader .read-post')];
400 berichten.forEach((a) => snap.addElement(a, { align: 'start' }));
401
402 // ALLEEN VOORUIT VANGEN. Dit hoort bij de ruime zone hierboven en is niet
403 // optioneel: lenis/snap kiest het DICHTSTBIJZIJNDE punt en zijn drempel geldt
404 // naar twee kanten. Scroll je een lang bericht in en stop je 300 pixels onder
405 // de bovenkant, dan is die bovenkant het dichtstbij -- en met een zone van 400
406 // wordt je teruggetrokken. Dat is precies de klacht "ik kan niet scrollen
407 // binnen een lang bericht", en met de oude zone van 86 pixels viel het alleen
408 // niet op.
409 //
410 // Dus: een doel ACHTER je slaan we over. Ligt er een punt voor je binnen de
411 // zone, dan gaan we daarheen; anders gebeurt er niets en scrol je vrij door.
412 // Zo verruimt de zone alleen de kant waar hij bedoeld is.
413 //
414 // Waarom niet gewoon de drempel? Omdat lenis/snap alleen de dichtstbijzijnde
415 // kandidaat beoordeelt: zonder deze omleiding houdt de zone vooruit op bij de
416 // helft van de afstand tussen twee berichten, hoe groot je de drempel ook zet.
417 //
418 // DE REGEL, in Robins woorden (20-8): "enkel bij downscrollen, aan de
419 // onderkant van elke post, snappen naar de lijn tussen de posts" -- en geen
420 // snap op een bericht dat al voorbij is gescrold. Drie voorwaarden dus:
421 // 1. de laatste echte beweging ging omlaag,
422 // 2. het doel ligt VOOR je (een punt achter je slaan we over),
423 // 3. het ligt binnen de vangzone -- die begint rond de voet van het bericht.
424 // Het doel zelf blijft de bovenkant van het volgende bericht: dat IS de lijn
425 // ertussen, en dat is het essentiële.
426 //
427 // DE ZONE IS EEN GETAL, geen '55%'. Die string kwam ongewijzigd uit de opties
428 // en werd hier met een getal vergeleken -- altijd onwaar, dus deze terugval
429 // heeft nooit gewerkt en alleen het doel dat lenis zelf koos kwam erdoor.
430 const vangZone = () => 0.12 * window.innerHeight; // gelijk aan distanceThreshold hierboven
431 // Lenis' eigen `direction` is het teken van de snelheid, en die is bij het
432 // afvuren van de (gedebouncede) snap alweer nul. Daarom onthouden we de
433 // laatste richting die er echt was.
434 let laatsteRichtingOmlaag = true;
435 lenis.on('scroll', () => {
436 if (Math.abs(lenis.velocity) > 0.05) laatsteRichtingOmlaag = lenis.velocity > 0;
437 });
438 const echtGaNaar = snap.goTo.bind(snap);
439 snap.goTo = (index) => {
440 if (!laatsteRichtingOmlaag) return; // omhoog: nooit vangen
441 const punten = snap.computeSnaps(); // zelfde volgorde als goTo intern gebruikt
442 const nu = lenis.scroll;
443 let vooruit = -1;
444 punten.forEach((punt, i) => {
445 if (punt.value > nu + 2 && (vooruit < 0 || punt.value < punten[vooruit].value)) vooruit = i;
446 });
447 if (vooruit < 0) return; // niets meer voor je: vrij uitscrollen
448 if (punten[vooruit].value - nu <= vangZone()) echtGaNaar(vooruit);
449 };
450
451 // OP TOUCH DOET DE MODULE-EIGEN TRIGGER HET NIET, en dat is meetbaar in de
452 // vendor-bron, geen vermoeden. Drie feiten op een rij:
453 //
454 // 1. lenis geeft 'virtual-scroll' door met de RUWE vingerdelta, VOOR hij
455 // de uitloop uitrekent (teken * |snelheid|^touchInertiaExponent).
456 // 2. lenis-snap negeert elke touchmove en beoordeelt dus EEN keer per
457 // gebaar, op touchend, met `scroll + rawDelta` -- de plek waar je
458 // vinger LOSLIET.
459 // 3. de uitloop draagt daarna nog honderden pixels verder.
460 //
461 // De snapbeslissing valt dus op een positie die de scroll meteen verlaat.
462 // Op desktop klopt dezelfde som wel: wieldelta's zijn klein en de
463 // smoothWheel-uitloop is kort, dus voorspelling en landing liggen bijeen.
464 // Vandaar: op touch de module-trigger eraf en zelf beoordelen tegen
465 // lenis.targetScroll -- die wordt synchroon bij touchend gezet en IS het
466 // exacte landingspunt van de uitloop.
467 if (OP_TOUCH.matches) {
468 lenis.off('virtual-scroll', snap.onSnapDebounced);
469 let raakTimer = null;
470 let vertrek = 0; // scrollpositie bij loslaten: punten daarachter zijn
471 // de bovenkant van het HUIDIGE bericht, nooit grijpen
472 lenis.on('virtual-scroll', (e) => {
473 const soort = e.event && e.event.type;
474 if (soort === 'touchstart' || soort === 'touchmove') { clearTimeout(raakTimer); return; }
475 if (soort !== 'touchend') return;
476 vertrek = lenis.scroll;
477 clearTimeout(raakTimer);
478 // TIMEOUT 0, geen 150ms. Dit event komt binnen VOOR lenis zelf de
479 // touchend verwerkt (de emit staat in de bron boven de verwerking), dus
480 // targetScroll is hier nog het oude doel. Een tik later is de uitloop
481 // gestart en staat targetScroll op de echte landing -- en dan sturen we
482 // hem meteen bij, in plaats van de uitloop 150ms te laten lopen en er
483 // dan een aparte snap-animatie overheen te zetten.
484 raakTimer = setTimeout(() => {
485 if (!laatsteRichtingOmlaag || snap.isStopped) return;
486 const doel = lenis.targetScroll; // de echte landing, niet de raakdelta
487 const punten = snap.computeSnaps();
488 // De EERSTE grens voorbij het loslaatpunt, niet de dichtstbijzijnde bij
489 // de landing. Robins regel (24-8): wordt het target voorbijgescrolld,
490 // dan landen we alsnog OP het target. De zone werkt dus alleen aan de
491 // korte kant -- kom je er niet eens bij in de buurt, dan lees je gewoon
492 // binnen een lang bericht en blijft alles vrij. Eroverheen, hoe ver
493 // ook, betekent grijpen. Een harde veeg komt daarmee altijd precies
494 // een bericht verder, nooit twee: dat is het bladeren.
495 let eerste = -1;
496 punten.forEach((punt, i) => {
497 if (punt.value > vertrek + 2 && (eerste < 0 || punt.value < punten[eerste].value)) eerste = i;
498 });
499 if (eerste < 0) return; // niets meer voor je: vrij uitscrollen
500 // HET RICHTEN IS DE SNAP (Robins regel, 24-8): geen aparte animatie
501 // met een eigen duur, maar de lopende uitloop een nieuw doel geven,
502 // met DEZELFDE demping (syncTouchLerp) waarmee hij al onderweg is.
503 // Zo is er een doorlopende beweging die toevallig op de lijn eindigt,
504 // in plaats van een glijvlucht met halverwege een muur.
505 if (doel >= punten[eerste].value - vangZone()) {
506 lenis.scrollTo(punten[eerste].value, { lerp: 0.09, force: true, userData: { initiator: 'snap' } });
507 }
508 }, 0);
509 });
510 }
511
512 // Native snappen uit: twee mechanismen op dezelfde scroller vechten.
513 document.documentElement.style.scrollSnapType = 'none';
514}
515
516function stopLenis() {
517 if (snap) { snap.destroy(); snap = null; }
518 if (lenis) { lenis.destroy(); lenis = null; }
519 document.documentElement.style.scrollSnapType = '';
520}
521
522/**
523 * Omhoog niet snappen. Met Lenis is dat snap.stop()/start(); zonder Lenis (dus
524 * op touch) zetten we de CSS-eigenschap om, precies zoals hiervoor.
525 */
526function zetSnappen(aan) {
527 if (snap) { if (aan) snap.start(); else snap.stop(); return; }
528 // Zonder Lenis (mobiel) zetten we de CSS-eigenschap om. Hij staat bij het
529 // laden UIT -- dat is de standaardstand van deze variabele -- zodat de browser
530 // niet meteen naar het eerste bericht springt en de header wegvalt. Pas je
531 // eerste gebaar naar beneden zet hem aan.
532 const el = document.documentElement;
533 const wil = aan ? '' : 'none';
534 if (el.style.scrollSnapType !== wil) el.style.scrollSnapType = wil;
535}
536
537/** Een richting geldt alleen als er NIETS beweegt. Anders negeren we hem. */
538function nieuwGebaar(naarBeneden) {
539 if (bezig) return;
540 bezig = true;
541 zetSnappen(naarBeneden);
542}
543
544/**
545 * Het gebaar is pas voorbij als de SCROLL stil is, niet als de vinger loslaat:
546 * op iOS loopt de uitloop daarna nog door. `scrollend` zegt dat precies, maar
547 * bestaat niet overal (Chrome 114+, Safari 17+) -- vandaar ook de timer.
548 */
549function rustNu() { bezig = false; }
550function planRust() {
551 clearTimeout(rustTimer);
552 rustTimer = setTimeout(rustNu, RUST_MS);
553}
554
555function opWiel(e) { if (Math.abs(e.deltaY) > 1) nieuwGebaar(e.deltaY > 0); }
556let raakY = 0;
557function opRaakStart(e) { if (e.touches && e.touches[0]) raakY = e.touches[0].clientY; }
558function opRaakBeweeg(e) {
559 if (!e.touches || !e.touches[0]) return;
560 const y = e.touches[0].clientY;
561 // Vinger omhoog = inhoud omlaag. Drie pixels speling tegen de trilling van een
562 // duim die stilstaat.
563 if (Math.abs(y - raakY) > 3) { nieuwGebaar(y < raakY); raakY = y; }
564}
565function opToets(e) {
566 if (['ArrowDown', 'PageDown', 'End', ' ', 'Spacebar'].indexOf(e.key) >= 0) nieuwGebaar(true);
567 else if (['ArrowUp', 'PageUp', 'Home'].indexOf(e.key) >= 0) nieuwGebaar(false);
568}
569
570let knop = null;
571let opScroll = null;
572
573export function init() {
574 // EERST OPRUIMEN, en pas daarna terugvallen als er geen leesstroom is.
575 //
576 // Dit stond andersom, en dat brak het scrollen op de HELE site: navigeerde je
577 // van Lezen naar het beheer, dan viel init() bij de ontbrekende leesstroom
578 // meteen terug -- en bleef Lenis leven EN gestopt (lenis.stop() vangt wiel en
579 // vinger af). Daarna scrolde niets meer, ook niet op pagina's die met Lezen
580 // niets te maken hebben. Robins melding (20-8): "scrollen werkt nu nergens ook
581 // niet in admin panels".
582 //
583 // Opruimen hoort dus bij het VERLATEN van de weergave, niet bij het opzetten
584 // ervan. init() draait bij elke paginawissel, dus dit is de plek.
585 stopLenis();
586 document.body.classList.remove('is-paged');
587 document.querySelectorAll('[data-lenis-prevent].read-post')
588 .forEach((a) => a.removeAttribute('data-lenis-prevent'));
589
590 const s = document.getElementById('read-stream');
591 if (!s) return;
592 // Op de stroom, niet per artikel: wat "meer laden" erbij zet doet vanzelf mee.
593 // init() draait bij ELKE paginawissel, dus eerst losmaken -- anders stapelt
594 // dezelfde afhandelaar zich op en vuurt hij twee keer.
595 s.removeEventListener('pointerdown', onPointerDown);
596 s.removeEventListener('click', onTap);
597 s.addEventListener('pointerdown', onPointerDown, { passive: true });
598 s.addEventListener('click', onTap);
599
600 // De knop staat in de HTML, zodat hij er ook is zonder deze module -- dan doet
601 // hij niets, maar hij springt niet in beeld bij het laden.
602 // De terug-naar-boven-knop staat er tijdelijk uit (Robin, 20-8) -- in
603 // paginamodus doet de bovenbalk dat werk al. De code blijft staan zodat hij
604 // met een regel terug is.
605 knop = null;
606 const oudeKnop = document.getElementById('read-top');
607 if (oudeKnop) oudeKnop.hidden = true;
608
609 if (opScroll) {
610 window.removeEventListener('scroll', opScroll);
611 window.removeEventListener('scrollend', rustNu);
612 window.removeEventListener('resize', opScroll);
613 window.removeEventListener('wheel', opWiel);
614 window.removeEventListener('touchstart', opRaakStart);
615 window.removeEventListener('touchmove', opRaakBeweeg);
616 window.removeEventListener('keydown', opToets);
617 }
618 // Elke scroll -- van een vinger of van de browser zelf -- houdt het gebaar
619 // levend; pas als het stil blijft mag een nieuwe richting gelden.
620 opScroll = () => {
621 if (knop) toonKnop(knop);
622 planRust();
623 if (document.body.classList.contains('is-paged')) zetBalken();
624 };
625 window.addEventListener('scroll', opScroll, { passive: true });
626 window.addEventListener('scrollend', rustNu);
627 window.addEventListener('resize', opScroll, { passive: true });
628 window.addEventListener('wheel', opWiel, { passive: true });
629 window.addEventListener('touchstart', opRaakStart, { passive: true });
630 window.addEventListener('touchmove', opRaakBeweeg, { passive: true });
631 window.addEventListener('keydown', opToets);
632
633 const balkBoven = document.getElementById('read-prev');
634 const balkOnder = document.getElementById('read-next-nav');
635 if (balkBoven) balkBoven.onclick = gaOmhoog;
636 if (balkOnder) balkOnder.onclick = () => gaNaar(huidigIndex() + 1);
637
638 // Alleen in de leesweergave, en alleen op desktop. Bij elke init() opnieuw
639 // beoordelen: van Grid naar Lezen schakelen hoort hem aan te zetten, en
640 // wegnavigeren hoort hem op te ruimen.
641 if (document.body.dataset.feedView === 'reader') startLenis().then(pasPaginaModusToe);
642 else pasPaginaModusToe();
643
644
645}
646
647export default { init };
Note: See TracBrowser for help on using the repository browser.