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

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

Lezen op mobiel: proef met het native snappen, achter een vlag

De vraag die deze proef beantwoordt is er precies een: houdt de browser zich
aan scroll-snap-stop: always? Die eigenschap zegt dat een uitloop nooit over
een snappunt heen mag vliegen, en dat is woord voor woord de regel van 24-8
(voorbijgescrolld of niet, je landt op het target) maar dan uitgevoerd door
het toestel met zijn eigen momentumcurve.

Dat overvliegen was destijds de reden om Lenis op mobiel erbij te halen, en
de eigenschap is toen nooit geprobeerd: hij stond op normal, en dan ook nog
op .read-post, een element dat door scroll-snap-align:none helemaal geen
snappunt is. Het ankertje dat het snappunt WEL is had hem niet.

Achter ?nativesnap=1 (en =0 om terug te gaan), onthouden in sessionStorage
omdat een htmx-navigatie de queryreeks weggooit. Staat de vlag aan, dan
blijft Lenis van de telefoon af en snapt de browser zelf. Standaard verandert
er dus niets, voor niemand, en de Lenis-kant blijft heel tot de proef gewonnen
is.

Een detail dat anders stil zou blijven staan: zetSnappen() zette het snappen
"aan" door de inline stijl leeg te maken, oftewel terugvallen op de
stylesheet. Die zet het op mobiel juist uit. Zonder Lenis zou het snappen
daarmee nooit aangaan; nu staat er expliciet 'y proximity'. Op desktop is dat
exact wat de stylesheet daar al geeft.

MOD_V 60 en style.css v=109, want beide bestanden wijzigen.

  • Property mode set to 100644
File size: 33.9 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
254/**
255 * DE PROEF: op mobiel het NATIVE snappen terug (24-8).
256 *
257 * De vraag die deze vlag beantwoordt is er precies een: houdt de browser zich
258 * aan `scroll-snap-stop: always`? Die eigenschap zegt dat een uitloop nooit
259 * over een snappunt heen mag vliegen -- en dat is woord voor woord Robins regel
260 * ("ook al zou het target voorbijgescrolld worden, alsnog op het target
261 * landen"), maar dan uitgevoerd door het toestel met zijn eigen momentumcurve
262 * in plaats van door onze nabootsing ervan in JavaScript.
263 *
264 * Dat overvliegen was destijds de reden om Lenis er op mobiel bij te halen. De
265 * eigenschap is toen nooit geprobeerd: hij stond op `normal`, en dan ook nog op
266 * .read-post -- een element dat door scroll-snap-align:none helemaal geen
267 * snappunt is. Het ankertje dat het snappunt WEL is had hem niet.
268 *
269 * ACHTER EEN VLAG, want de Lenis-kant blijft staan tot deze proef gewonnen is.
270 * Wint hij, dan kan Lenis van de telefoon af en verdwijnen daarmee ook de vier
271 * routes waarlangs de native scroll het gebaar nu soms overneemt. Verliest hij,
272 * dan hebben we dat geweten voordat er iets weg was.
273 *
274 * ?nativesnap=1 zet hem aan, ?nativesnap=0 weer uit. De keuze blijft staan
275 * zolang het tabblad open is, want een htmx-navigatie gooit de queryreeks weg
276 * en dan zou de proef na een klik voorbij zijn.
277 */
278function nativeSnap() {
279 try {
280 const q = new URLSearchParams(location.search).get('nativesnap');
281 if (q === '1') sessionStorage.setItem('klonkt:native-snap', '1');
282 if (q === '0') sessionStorage.removeItem('klonkt:native-snap');
283 return sessionStorage.getItem('klonkt:native-snap') === '1';
284 } catch (e) {
285 return false; // privémodus zonder opslag: gewoon de gewone weg
286 }
287}
288
289let bezig = false; // loopt er een gebaar of een uitloop?
290let ooitGescrold = false; // snappen begint UIT, zie zetSnappen()
291let rustTimer = null;
292
293/**
294 * LENIS, en alleen op desktop.
295 *
296 * Op touch doet Lenis van zichzelf niets (syncTouch staat standaard uit) en is
297 * het systeem-scrollen al soepel -- daar blijft de native CSS-snap staan die
298 * hierboven beschreven is. Robins keuze (20-8): "enkel voor desktop, dat is
299 * prima, logisch dat het niet op mobiel kan".
300 *
301 * WAAROM LENIS HIER STAAT, en dat is niet het vloeiende scrollen: de DUUR van
302 * een snap is met native scroll-snap niet in te stellen -- die zit in de browser.
303 * Lenis' snap-pakket wel: duration, easing, distanceThreshold en debounce zijn
304 * allemaal van ons. Dat was de aanleiding.
305 *
306 * Het vloeiende scrollen (smoothWheel) kwam er daarna bij, en dat is de kant die
307 * OPPASSEN vraagt. Een Mac-trackpad heeft zijn EIGEN momentum, en Lenis'
308 * demping komt daar bovenop -- dubbel gedempt voelt drijverig. Vandaar lerp 0.2
309 * in plaats van de standaard 0.1. Robin vond dat op 20-8 nog steeds te zweverig,
310 * dus dat getal is nog niet uit; zie de opmerking bij de instellingen.
311 *
312 * De stand met `smoothWheel: false` werkte ook, en dan doet Lenis alleen de
313 * snap. Dat is de terugvalpositie als het vloeiende scrollen niet bevalt.
314 *
315 * lenis/snap haakt alleen in op lenis.on('scroll') en roept lenis.scrollTo aan
316 * (nagekeken in de dist), dus die opzet werkt.
317 */
318const OP_DESKTOP = window.matchMedia('(hover: hover) and (pointer: fine)');
319// PROEF (20-8): Lenis ook op touch. Robin meldde op mobiel drie dingen -- de
320// header niet zichtbaar bij laden, snappen dat soms te ver gaat, en horizontaal
321// kunnen scrollen. De eerste twee komen van de NATIVE snap: die vangt al bij het
322// laden, en zijn uitloop vliegt bij een flick over een grens heen. Lenis zet de
323// native snap uit en doet het zelf, dus die twee kunnen ermee verdwijnen.
324//
325// De prijs is echt: syncTouch betekent dat Lenis het VINGERSCROLLEN overneemt
326// van iOS -- momentum, rubber-band en het wegschuiven van de adresbalk worden
327// dan een benadering in JavaScript. De makers waarschuwen daar zelf voor op
328// iOS < 16. Dit staat er dus als proef, niet als besluit: voelt het niet goed,
329// dan is OP_TOUCH weer false zetten de hele terugweg.
330const OP_TOUCH = window.matchMedia('(hover: none) and (pointer: coarse)');
331const VENDOR_V = 1; // ophogen als de bestanden in /assets/js/vendor wijzigen
332
333let lenis = null;
334let snap = null;
335
336async function startLenis() {
337 // DESKTOP EN TOUCH ALLEBEI. Er heeft hier een tijd gestaan dat mobiel terug was
338 // op de systeemscroll; dat klopte niet meer met de regel hieronder, die
339 // syncTouch juist AANzet op touch. Reden dat Lenis ook op de telefoon meedraait:
340 // zijn snap heeft zijn scroll-gebeurtenissen nodig om te kunnen timen.
341 if (lenis || !(OP_DESKTOP.matches || OP_TOUCH.matches)) return;
342 // Staat de proef aan, dan blijft Lenis van de telefoon af en doet de browser
343 // het snappen zelf. Desktop merkt hier niets van: daar is OP_TOUCH onwaar.
344 if (OP_TOUCH.matches && nativeSnap()) return;
345 const [L, S] = await Promise.all([
346 import(`/assets/vendor/lenis.mjs?v=${VENDOR_V}`),
347 import(`/assets/vendor/lenis-snap.mjs?v=${VENDOR_V}`),
348 ]);
349 lenis = new L.default({
350 /**
351 * Wat Lenis MET RUST LAAT.
352 *
353 * In paginamodus staat Lenis stil en scrolt alleen het paneel van het
354 * bericht zelf. Dat regelde ik eerst met een data-lenis-prevent-attribuut
355 * dat de module op elk paneel zette -- maar dan hangt het scrollen af van of
356 * dat attribuut op tijd en op elk (ook later bijgeladen) paneel staat, en
357 * Robin kon binnen een lang bericht niet scrollen.
358 *
359 * Deze functie stelt dezelfde vraag zonder die afhankelijkheid: is dit een
360 * bericht-paneel? Dan bemoeit Lenis zich er niet mee en scrolt de browser
361 * het zelf -- wat op een telefoon precies is wat je wilt, want dat is de
362 * systeemscroll.
363 */
364 prevent: (node) => !!(node && node.classList
365 && node.classList.contains('read-post')
366 && document.body.classList.contains('is-paged')),
367 // Lenis tekent de scrollbeweging zelf, maar STEVIG GEDEMPT (lerp 0.2 in
368 // plaats van de standaard 0.1). Reden: een muiswiel scrollt in schokken en
369 // heeft die demping nodig; een Mac-trackpad heeft zijn EIGEN momentum en
370 // krijgt er dan een tweede overheen -- dat is precies het drijverige gevoel
371 // waar Robin voor waarschuwde. Hoger betekent korter naijlen, dus dit is de
372 // middenweg: de schokjes weg, de nasleep kort.
373 // Staat het toch te zweven, dan is lerp omhoog (richting 1) of terug naar
374 // smoothWheel:false de knop -- die stand werkte ook, met alleen de snap.
375 smoothWheel: true,
376 lerp: 0.2,
377 // Alleen op touch overneemt hij het vingerscrollen; op desktop hoeft dat niet.
378 syncTouch: OP_TOUCH.matches,
379 // Twee touch-knoppen, en ze doen echt iets anders. Lenis rekent bij het
380 // LOSLATEN eenmalig een doel uit -- afstand = snelheid^touchInertiaExponent
381 // -- en kruipt daar dan naartoe met syncTouchLerp.
382 //
383 // touchInertiaExponent hoe VER de flick draagt (standaard 1.7)
384 // syncTouchLerp hoe hard hij REMT (standaard 0.075)
385 //
386 // Let op: `touchInertiaMultiplier` bestaat NIET in 1.3.26. Lenis slikt
387 // onbekende opties zonder fout, dus zo'n regel lijkt te werken en doet niets.
388 // Controleer een optienaam in src/assets/vendor/lenis.mjs voor je hem zet.
389 //
390 // Robin wilde langer en sneller uitrollen zonder remgevoel, dus de exponent
391 // gaat omhoog (een stevige flick draagt daarmee ruim vier tot zes keer zo
392 // ver) en de lerp gaat juist boven de standaard: hij legt die afstand vlot
393 // af in plaats van er stroperig naartoe te kruipen.
394 ...(OP_TOUCH.matches ? { syncTouchLerp: 0.09, touchInertiaExponent: 2.2 } : {}),
395 autoRaf: true,
396 });
397 snap = new S.default(lenis, {
398 type: 'proximity',
399 // DE VANGZONE, en dit is HET getal om aan te draaien. Ooit 12%, toen 55%
400 // (20-8: eerder vangen, boven de reacties), en 20-8 terug naar 12% -- want
401 // sinds het snappen alleen nog vooruit werkt, voelde die brede zone als
402 // trekken tijdens het lezen. 12% is ~110 pixels op een venster van 910: hij
403 // vangt pas vlak vóór de lijn.
404 // Houd dit gelijk aan vangZone() hieronder: deze drempel bepaalt of
405 // lenis/snap überhaupt aanklopt, die andere of wij het doorlaten. Zet je
406 // deze lager dan die, dan komt onze regel nooit aan bod.
407 distanceThreshold: '12%',
408 // Hoe lang na de laatste scrollbeweging hij mag vangen. De standaard is 500
409 // en dat voelt als te laat. Op touch nog korter dan op desktop, want daar
410 // eindigt een veeg in een lange, trage staart -- en juist dan wil je dat de
411 // snap er snel bij is in plaats van te wachten tot de laatste pixel stil ligt.
412 //
413 // LET OP DE SPANNING: een vloeiendere uitloop (lagere syncTouchLerp) maakt
414 // die staart langer, en stelt de snap dus uit. Deze twee getallen houden
415 // elkaar in evenwicht; draai je aan de een, kijk dan ook naar de ander.
416 // OP TOUCH JUIST LANG, en dat is het omgekeerde van wat ik vorige ronde deed.
417 // Tussen je vinger loslaten en het op gang komen van de uitloop zit een korte
418 // stilte in de scrollbeweging. Met 25ms viel de snap precies in dat gat: hij
419 // greep op het moment van loslaten en knipte de veeg af -- wat aanvoelt als
420 // "stroef bij het loslaten", en het werd er dan ook erger van. 200ms laat de
421 // uitloop eerst zijn werk doen.
422 // Met een iOS-achtige uitloop duurt de staart lang; te kort wachten laat de
423 // snap midden in de vlucht ingrijpen. 150ms laat hem uitrollen en vangt dan.
424 debounce: OP_TOUCH.matches ? 150 : 60,
425 // Op touch korter: een telefoon vraagt om directer antwoord dan een muis, en
426 // de snap komt daar aan het eind van een lange uitloop -- dan mag hij kort.
427 duration: OP_TOUCH.matches ? 0.18 : 0.4,
428 // Vlot weg, dan steeds langzamer aankomen (Robin, 20-8). easeOutQuart: op de
429 // helft van de tijd is 94% van de weg af, en de rest dempt zacht uit.
430 // Bewust NIET Lenis' standaard easeOutExpo -- die schiet weg en kruipt dan
431 // zo lang na dat het lijkt of hij niet afmaakt.
432 easing: (t) => 1 - Math.pow(1 - t, 4),
433 });
434 // Het snappunt is de bovenkant van elk bericht -- ook van het laatste. Dat
435 // deed eerder niet mee omdat zijn bovenkant onbereikbaar was; sinds het
436 // laatste bericht in CSS een volle schermhoogte krijgt, kan hij wel.
437 const berichten = [...document.querySelectorAll('.feed-reader .read-post')];
438 berichten.forEach((a) => snap.addElement(a, { align: 'start' }));
439
440 // ALLEEN VOORUIT VANGEN. Dit hoort bij de ruime zone hierboven en is niet
441 // optioneel: lenis/snap kiest het DICHTSTBIJZIJNDE punt en zijn drempel geldt
442 // naar twee kanten. Scroll je een lang bericht in en stop je 300 pixels onder
443 // de bovenkant, dan is die bovenkant het dichtstbij -- en met een zone van 400
444 // wordt je teruggetrokken. Dat is precies de klacht "ik kan niet scrollen
445 // binnen een lang bericht", en met de oude zone van 86 pixels viel het alleen
446 // niet op.
447 //
448 // Dus: een doel ACHTER je slaan we over. Ligt er een punt voor je binnen de
449 // zone, dan gaan we daarheen; anders gebeurt er niets en scrol je vrij door.
450 // Zo verruimt de zone alleen de kant waar hij bedoeld is.
451 //
452 // Waarom niet gewoon de drempel? Omdat lenis/snap alleen de dichtstbijzijnde
453 // kandidaat beoordeelt: zonder deze omleiding houdt de zone vooruit op bij de
454 // helft van de afstand tussen twee berichten, hoe groot je de drempel ook zet.
455 //
456 // DE REGEL, in Robins woorden (20-8): "enkel bij downscrollen, aan de
457 // onderkant van elke post, snappen naar de lijn tussen de posts" -- en geen
458 // snap op een bericht dat al voorbij is gescrold. Drie voorwaarden dus:
459 // 1. de laatste echte beweging ging omlaag,
460 // 2. het doel ligt VOOR je (een punt achter je slaan we over),
461 // 3. het ligt binnen de vangzone -- die begint rond de voet van het bericht.
462 // Het doel zelf blijft de bovenkant van het volgende bericht: dat IS de lijn
463 // ertussen, en dat is het essentiële.
464 //
465 // DE ZONE IS EEN GETAL, geen '55%'. Die string kwam ongewijzigd uit de opties
466 // en werd hier met een getal vergeleken -- altijd onwaar, dus deze terugval
467 // heeft nooit gewerkt en alleen het doel dat lenis zelf koos kwam erdoor.
468 const vangZone = () => 0.12 * window.innerHeight; // gelijk aan distanceThreshold hierboven
469 // Lenis' eigen `direction` is het teken van de snelheid, en die is bij het
470 // afvuren van de (gedebouncede) snap alweer nul. Daarom onthouden we de
471 // laatste richting die er echt was.
472 let laatsteRichtingOmlaag = true;
473 lenis.on('scroll', () => {
474 if (Math.abs(lenis.velocity) > 0.05) laatsteRichtingOmlaag = lenis.velocity > 0;
475 });
476 const echtGaNaar = snap.goTo.bind(snap);
477 snap.goTo = (index) => {
478 if (!laatsteRichtingOmlaag) return; // omhoog: nooit vangen
479 const punten = snap.computeSnaps(); // zelfde volgorde als goTo intern gebruikt
480 const nu = lenis.scroll;
481 let vooruit = -1;
482 punten.forEach((punt, i) => {
483 if (punt.value > nu + 2 && (vooruit < 0 || punt.value < punten[vooruit].value)) vooruit = i;
484 });
485 if (vooruit < 0) return; // niets meer voor je: vrij uitscrollen
486 if (punten[vooruit].value - nu <= vangZone()) echtGaNaar(vooruit);
487 };
488
489 // OP TOUCH DOET DE MODULE-EIGEN TRIGGER HET NIET, en dat is meetbaar in de
490 // vendor-bron, geen vermoeden. Drie feiten op een rij:
491 //
492 // 1. lenis geeft 'virtual-scroll' door met de RUWE vingerdelta, VOOR hij
493 // de uitloop uitrekent (teken * |snelheid|^touchInertiaExponent).
494 // 2. lenis-snap negeert elke touchmove en beoordeelt dus EEN keer per
495 // gebaar, op touchend, met `scroll + rawDelta` -- de plek waar je
496 // vinger LOSLIET.
497 // 3. de uitloop draagt daarna nog honderden pixels verder.
498 //
499 // De snapbeslissing valt dus op een positie die de scroll meteen verlaat.
500 // Op desktop klopt dezelfde som wel: wieldelta's zijn klein en de
501 // smoothWheel-uitloop is kort, dus voorspelling en landing liggen bijeen.
502 // Vandaar: op touch de module-trigger eraf en zelf beoordelen tegen
503 // lenis.targetScroll -- die wordt synchroon bij touchend gezet en IS het
504 // exacte landingspunt van de uitloop.
505 if (OP_TOUCH.matches) {
506 // Hoe lang het richten naar de lijn duurt, in seconden. De ene knop voor
507 // "snapperiger": 0.18 is direct, 0.3 is landen, 0.5 is zweven.
508 const SNAP_DUUR = 0.22;
509 lenis.off('virtual-scroll', snap.onSnapDebounced);
510 let raakTimer = null;
511 let vertrek = 0; // scrollpositie bij loslaten: punten daarachter zijn
512 // de bovenkant van het HUIDIGE bericht, nooit grijpen
513 lenis.on('virtual-scroll', (e) => {
514 const soort = e.event && e.event.type;
515 if (soort === 'touchstart' || soort === 'touchmove') { clearTimeout(raakTimer); return; }
516 if (soort !== 'touchend') return;
517 vertrek = lenis.scroll;
518 clearTimeout(raakTimer);
519 // TIMEOUT 0, geen 150ms. Dit event komt binnen VOOR lenis zelf de
520 // touchend verwerkt (de emit staat in de bron boven de verwerking), dus
521 // targetScroll is hier nog het oude doel. Een tik later is de uitloop
522 // gestart en staat targetScroll op de echte landing -- en dan sturen we
523 // hem meteen bij, in plaats van de uitloop 150ms te laten lopen en er
524 // dan een aparte snap-animatie overheen te zetten.
525 raakTimer = setTimeout(() => {
526 if (!laatsteRichtingOmlaag || snap.isStopped) return;
527 const doel = lenis.targetScroll; // de echte landing, niet de raakdelta
528 const punten = snap.computeSnaps();
529 // De EERSTE grens voorbij het loslaatpunt, niet de dichtstbijzijnde bij
530 // de landing. Robins regel (24-8): wordt het target voorbijgescrolld,
531 // dan landen we alsnog OP het target. De zone werkt dus alleen aan de
532 // korte kant -- kom je er niet eens bij in de buurt, dan lees je gewoon
533 // binnen een lang bericht en blijft alles vrij. Eroverheen, hoe ver
534 // ook, betekent grijpen. Een harde veeg komt daarmee altijd precies
535 // een bericht verder, nooit twee: dat is het bladeren.
536 let eerste = -1;
537 punten.forEach((punt, i) => {
538 if (punt.value > vertrek + 2 && (eerste < 0 || punt.value < punten[eerste].value)) eerste = i;
539 });
540 if (eerste < 0) return; // niets meer voor je: vrij uitscrollen
541 // HET RICHTEN IS DE SNAP (Robins regel, 24-8): de lopende uitloop
542 // krijgt het nieuwe doel, er komt geen tweede animatie overheen.
543 //
544 // MET EEN VASTE DUUR, niet met de lerp van de uitloop. Die lerp-vorm
545 // (0.09) is boterzacht maar heeft een exponentiele staart van ruim een
546 // halve seconde -- Robin wilde het korter en beslister (24-8). Een
547 // duur eindigt ECHT, op een instelbaar moment, zoals iOS-paging: vlot
548 // weg, stevig landen. Zelfde easing-familie als de desktop-snap.
549 // SNAP_DUUR is de enige knop: korter is snapperiger, langer is zachter.
550 if (doel >= punten[eerste].value - vangZone()) {
551 lenis.scrollTo(punten[eerste].value, {
552 duration: SNAP_DUUR,
553 easing: (t) => 1 - Math.pow(1 - t, 4),
554 force: true,
555 userData: { initiator: 'snap' },
556 });
557 }
558 }, 0);
559 });
560 }
561
562 // Native snappen uit: twee mechanismen op dezelfde scroller vechten.
563 document.documentElement.style.scrollSnapType = 'none';
564}
565
566function stopLenis() {
567 if (snap) { snap.destroy(); snap = null; }
568 if (lenis) { lenis.destroy(); lenis = null; }
569 document.documentElement.style.scrollSnapType = '';
570}
571
572/**
573 * Omhoog niet snappen. Met Lenis is dat snap.stop()/start(); zonder Lenis (dus
574 * op touch) zetten we de CSS-eigenschap om, precies zoals hiervoor.
575 */
576function zetSnappen(aan) {
577 if (snap) { if (aan) snap.start(); else snap.stop(); return; }
578 // Zonder Lenis (mobiel) zetten we de CSS-eigenschap om. Hij staat bij het
579 // laden UIT -- dat is de standaardstand van deze variabele -- zodat de browser
580 // niet meteen naar het eerste bericht springt en de header wegvalt. Pas je
581 // eerste gebaar naar beneden zet hem aan.
582 const el = document.documentElement;
583 // EXPLICIET 'y proximity', geen lege waarde. Leeg betekent "val terug op de
584 // stylesheet", en die zet het snappen op mobiel juist UIT (de mediaquery bij
585 // .read-post, want twee snapsystemen op dezelfde scroller vechten). Zolang
586 // Lenis daar draaide klopte dat; zonder Lenis zou het snappen dan nooit meer
587 // aangaan en zou de proef niets kunnen bewijzen.
588 // Op desktop verandert er niets: dit is exact wat de stylesheet daar geeft.
589 const wil = aan ? 'y proximity' : 'none';
590 if (el.style.scrollSnapType !== wil) el.style.scrollSnapType = wil;
591}
592
593/** Een richting geldt alleen als er NIETS beweegt. Anders negeren we hem. */
594function nieuwGebaar(naarBeneden) {
595 if (bezig) return;
596 bezig = true;
597 zetSnappen(naarBeneden);
598}
599
600/**
601 * Het gebaar is pas voorbij als de SCROLL stil is, niet als de vinger loslaat:
602 * op iOS loopt de uitloop daarna nog door. `scrollend` zegt dat precies, maar
603 * bestaat niet overal (Chrome 114+, Safari 17+) -- vandaar ook de timer.
604 */
605function rustNu() { bezig = false; }
606function planRust() {
607 clearTimeout(rustTimer);
608 rustTimer = setTimeout(rustNu, RUST_MS);
609}
610
611function opWiel(e) { if (Math.abs(e.deltaY) > 1) nieuwGebaar(e.deltaY > 0); }
612let raakY = 0;
613function opRaakStart(e) { if (e.touches && e.touches[0]) raakY = e.touches[0].clientY; }
614function opRaakBeweeg(e) {
615 if (!e.touches || !e.touches[0]) return;
616 const y = e.touches[0].clientY;
617 // Vinger omhoog = inhoud omlaag. Drie pixels speling tegen de trilling van een
618 // duim die stilstaat.
619 if (Math.abs(y - raakY) > 3) { nieuwGebaar(y < raakY); raakY = y; }
620}
621function opToets(e) {
622 if (['ArrowDown', 'PageDown', 'End', ' ', 'Spacebar'].indexOf(e.key) >= 0) nieuwGebaar(true);
623 else if (['ArrowUp', 'PageUp', 'Home'].indexOf(e.key) >= 0) nieuwGebaar(false);
624}
625
626let knop = null;
627let opScroll = null;
628
629export function init() {
630 // EERST OPRUIMEN, en pas daarna terugvallen als er geen leesstroom is.
631 //
632 // Dit stond andersom, en dat brak het scrollen op de HELE site: navigeerde je
633 // van Lezen naar het beheer, dan viel init() bij de ontbrekende leesstroom
634 // meteen terug -- en bleef Lenis leven EN gestopt (lenis.stop() vangt wiel en
635 // vinger af). Daarna scrolde niets meer, ook niet op pagina's die met Lezen
636 // niets te maken hebben. Robins melding (20-8): "scrollen werkt nu nergens ook
637 // niet in admin panels".
638 //
639 // Opruimen hoort dus bij het VERLATEN van de weergave, niet bij het opzetten
640 // ervan. init() draait bij elke paginawissel, dus dit is de plek.
641 stopLenis();
642 document.body.classList.remove('is-paged');
643 document.querySelectorAll('[data-lenis-prevent].read-post')
644 .forEach((a) => a.removeAttribute('data-lenis-prevent'));
645
646 const s = document.getElementById('read-stream');
647 if (!s) return;
648 // Op de stroom, niet per artikel: wat "meer laden" erbij zet doet vanzelf mee.
649 // init() draait bij ELKE paginawissel, dus eerst losmaken -- anders stapelt
650 // dezelfde afhandelaar zich op en vuurt hij twee keer.
651 s.removeEventListener('pointerdown', onPointerDown);
652 s.removeEventListener('click', onTap);
653 s.addEventListener('pointerdown', onPointerDown, { passive: true });
654 s.addEventListener('click', onTap);
655
656 // De knop staat in de HTML, zodat hij er ook is zonder deze module -- dan doet
657 // hij niets, maar hij springt niet in beeld bij het laden.
658 // De terug-naar-boven-knop staat er tijdelijk uit (Robin, 20-8) -- in
659 // paginamodus doet de bovenbalk dat werk al. De code blijft staan zodat hij
660 // met een regel terug is.
661 knop = null;
662 const oudeKnop = document.getElementById('read-top');
663 if (oudeKnop) oudeKnop.hidden = true;
664
665 if (opScroll) {
666 window.removeEventListener('scroll', opScroll);
667 window.removeEventListener('scrollend', rustNu);
668 window.removeEventListener('resize', opScroll);
669 window.removeEventListener('wheel', opWiel);
670 window.removeEventListener('touchstart', opRaakStart);
671 window.removeEventListener('touchmove', opRaakBeweeg);
672 window.removeEventListener('keydown', opToets);
673 }
674 // Elke scroll -- van een vinger of van de browser zelf -- houdt het gebaar
675 // levend; pas als het stil blijft mag een nieuwe richting gelden.
676 opScroll = () => {
677 if (knop) toonKnop(knop);
678 planRust();
679 if (document.body.classList.contains('is-paged')) zetBalken();
680 };
681 window.addEventListener('scroll', opScroll, { passive: true });
682 window.addEventListener('scrollend', rustNu);
683 window.addEventListener('resize', opScroll, { passive: true });
684 window.addEventListener('wheel', opWiel, { passive: true });
685 window.addEventListener('touchstart', opRaakStart, { passive: true });
686 window.addEventListener('touchmove', opRaakBeweeg, { passive: true });
687 window.addEventListener('keydown', opToets);
688
689 const balkBoven = document.getElementById('read-prev');
690 const balkOnder = document.getElementById('read-next-nav');
691 if (balkBoven) balkBoven.onclick = gaOmhoog;
692 if (balkOnder) balkOnder.onclick = () => gaNaar(huidigIndex() + 1);
693
694 // Alleen in de leesweergave, en alleen op desktop. Bij elke init() opnieuw
695 // beoordelen: van Grid naar Lezen schakelen hoort hem aan te zetten, en
696 // wegnavigeren hoort hem op te ruimen.
697 if (document.body.dataset.feedView === 'reader') startLenis().then(pasPaginaModusToe);
698 else pasPaginaModusToe();
699
700
701}
702
703export default { init };
Note: See TracBrowser for help on using the repository browser.