source: Klonkt/src/assets/js/mod/read.js@ 369aa60

main
Last change on this file since 369aa60 was 369aa60, checked in by Robin <roboburr@…>, 4 days ago

Cache-busting reikt nu ook tot wat de modules zelf ophalen (shaer-724)

De bootstrap hing al ?v= aan elke module die hij zelf laadt. Twee soorten
paden ontsnapten daaraan, en /assets wordt buiten ontwikkeling een jaar
gecachet:

een import BINNEN een module is relatief, en zo\x27n specifier erft de
query niet: ./lib.js naast post.js?v=63 wordt gewoon
/assets/js/mod/lib.js. Elf modules importeren lib.js zo, en juist dat
bestand is gedeeld -- een fout erin overleefde elke MOD_V-bump.

een vendorbestand dat een module zelf ophaalt. read.js deed het goed met
?v=VENDOR_V; lib.js (mijn eigen import van gisteren) en de twee
cropper-verwijzingen in post-edit deden het niet.

Een importmap in de head lost het eerste op zonder die elf imports aan te
raken: hij vertaalt de opgeloste URL naar zijn geversioneerde vorm. MOD_V
staat daarvoor nu als EJS-variabele bovenaan de shell, zodat de importmap
en de bootstrap niet twee nummers kunnen worden. VENDOR_V staat in lib.js
en wordt door read.js en post-edit gedeeld, om dezelfde reden.

Drie toetsen die de REGEL bewaken en niet deze ene plek: elke relatieve
import tussen modules heeft een ingang in de importmap, elk vendorpad
draagt een versie, en de twee nummers zijn er een. Tegenbewijs: haal de
ingang weg of de versie eraf en precies die toets valt.

Volle suite 1248 groen.

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