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

main
Last change on this file since c581e5e was c581e5e, checked in by Claude (agent) <aiclaude@…>, 3 weeks ago

proef: lenis ook op touch

  • Property mode set to 100644
File size: 17.3 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/**
34 * Terug naar boven, en bewust NIET window.scrollTo(0).
35 *
36 * In een stroom wil je terug naar het BEGIN VAN DIT BERICHT als je halverwege een
37 * lang stuk zit, en pas daarna naar de kop van de pagina. Twee keer drukken doet
38 * dus twee verschillende dingen -- dat scheelt op mobiel een hoop vegen.
39 */
40function naarBoven() {
41 const zacht = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
42 const gedrag = zacht ? 'smooth' : 'auto';
43 const posts = [...document.querySelectorAll('.feed-reader .read-post')];
44 const huidig = posts.find((a) => {
45 const r = a.getBoundingClientRect();
46 return r.top <= 8 && r.bottom > 8;
47 });
48 // Sta je al bovenaan dit bericht (of bij het eerste), dan naar de paginakop.
49 if (huidig && huidig.getBoundingClientRect().top < -8) {
50 huidig.scrollIntoView({ behavior: gedrag, block: 'start' });
51 return;
52 }
53 window.scrollTo({ top: 0, behavior: gedrag });
54}
55
56/** De knop verschijnt pas als er iets ONDER je ligt om naar terug te keren. */
57function toonKnop(knop) {
58 knop.classList.toggle('is-zichtbaar', window.scrollY > window.innerHeight * 0.6);
59}
60
61let tapX = 0, tapY = 0;
62function onPointerDown(e) { tapX = e.clientX; tapY = e.clientY; }
63
64function onTap(e) {
65 if (e.defaultPrevented || e.button !== 0) return;
66 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
67 const t = e.target;
68 if (!t || typeof t.closest !== 'function') return;
69 const art = t.closest('.read-post');
70 if (!art) return;
71 if (t.closest('a, button, input, textarea, select, label, summary, [role="button"]')) return;
72 if (Math.abs(e.clientX - tapX) > 10 || Math.abs(e.clientY - tapY) > 10) return;
73 const sel = window.getSelection && window.getSelection();
74 if (sel && String(sel).trim()) return;
75 const slug = art.dataset.slug;
76 if (!slug) return;
77 location.href = (art.dataset.base || '') + '/' + encodeURIComponent(slug);
78}
79
80/**
81 * Omhoog scrollen blijft VRIJ (Robin, 20-8).
82 *
83 * Snappen hoort bij doorlezen; ga je terug, dan zoek je iets en bepaal je zelf
84 * waar je stopt. CSS kent geen richtingsgevoelig snappen, dus dat ene stukje
85 * moet hier -- maar dan ook niet meer dan dat: we zetten de CSS-functie aan of
86 * uit op de scroller. Geen vangzones, geen snappunten per bericht, geen
87 * scrollpositie-boekhouding.
88 *
89 * DE RICHTING KOMT UIT DE INVOER, niet uit scroll-events. Gemeten op dev: die
90 * events bevatten ook de bewegingen van de browser zelf -- zijn snap-animatie en
91 * de rubber-band -- en die gaan soms omhoog. Wie daaruit de richting afleidt,
92 * leest de browser en niet de gebruiker.
93 *
94 * EN ER WORDT ALLEEN GESCHAKELD BIJ STILSTAND. Dat is de tweede les, en die
95 * kostte een ronde. Eerst zette dit de schakelaar om bij ELK wiel-event en ELKE
96 * vingerbeweging, dus binnen een veeg klapte hij meerdere keren heen en weer.
97 * De browser raadpleegt scroll-snap-type alleen aan het eind van een gebaar of
98 * van de uitloop, en of je daar net vóór of net ná zit bepaalt dan of er
99 * gesnapt wordt. Gemeten (Robins melding "soms triggert het terwijl we nog aan
100 * het doorscrollen zijn"):
101 *
102 * snappen UIT, beweging naar 1459 loopt
103 * -> halverwege snappen AAN gezet
104 * -> eindigt op 1459, NIET op een berichtgrens
105 *
106 * Bij stilstand omzetten is wel onschuldig: dezelfde proef gaf 0px sprong.
107 * Vandaar: richting bepalen bij het BEGIN van een gebaar, en daarna niets meer
108 * aanraken tot de scroll echt stil is.
109 *
110 * Wat je daarvoor inlevert: binnen een veeg ligt de stand vast. Draai je
111 * halverwege om zonder los te laten, dan geldt de stand van dat gebaar nog. Een
112 * besluit per gebaar is voorspelbaar; het omklappen halverwege was het probleem.
113 */
114const RUST_MS = 120;
115
116let bezig = false; // loopt er een gebaar of een uitloop?
117let rustTimer = null;
118
119/**
120 * LENIS, en alleen op desktop.
121 *
122 * Op touch doet Lenis van zichzelf niets (syncTouch staat standaard uit) en is
123 * het systeem-scrollen al soepel -- daar blijft de native CSS-snap staan die
124 * hierboven beschreven is. Robins keuze (20-8): "enkel voor desktop, dat is
125 * prima, logisch dat het niet op mobiel kan".
126 *
127 * WAAROM LENIS HIER STAAT, en dat is niet het vloeiende scrollen: de DUUR van
128 * een snap is met native scroll-snap niet in te stellen -- die zit in de browser.
129 * Lenis' snap-pakket wel: duration, easing, distanceThreshold en debounce zijn
130 * allemaal van ons. Dat was de aanleiding.
131 *
132 * Het vloeiende scrollen (smoothWheel) kwam er daarna bij, en dat is de kant die
133 * OPPASSEN vraagt. Een Mac-trackpad heeft zijn EIGEN momentum, en Lenis'
134 * demping komt daar bovenop -- dubbel gedempt voelt drijverig. Vandaar lerp 0.2
135 * in plaats van de standaard 0.1. Robin vond dat op 20-8 nog steeds te zweverig,
136 * dus dat getal is nog niet uit; zie de opmerking bij de instellingen.
137 *
138 * De stand met `smoothWheel: false` werkte ook, en dan doet Lenis alleen de
139 * snap. Dat is de terugvalpositie als het vloeiende scrollen niet bevalt.
140 *
141 * lenis/snap haakt alleen in op lenis.on('scroll') en roept lenis.scrollTo aan
142 * (nagekeken in de dist), dus die opzet werkt.
143 */
144const OP_DESKTOP = window.matchMedia('(hover: hover) and (pointer: fine)');
145// PROEF (20-8): Lenis ook op touch. Robin meldde op mobiel drie dingen -- de
146// header niet zichtbaar bij laden, snappen dat soms te ver gaat, en horizontaal
147// kunnen scrollen. De eerste twee komen van de NATIVE snap: die vangt al bij het
148// laden, en zijn uitloop vliegt bij een flick over een grens heen. Lenis zet de
149// native snap uit en doet het zelf, dus die twee kunnen ermee verdwijnen.
150//
151// De prijs is echt: syncTouch betekent dat Lenis het VINGERSCROLLEN overneemt
152// van iOS -- momentum, rubber-band en het wegschuiven van de adresbalk worden
153// dan een benadering in JavaScript. De makers waarschuwen daar zelf voor op
154// iOS < 16. Dit staat er dus als proef, niet als besluit: voelt het niet goed,
155// dan is OP_TOUCH weer false zetten de hele terugweg.
156const OP_TOUCH = window.matchMedia('(hover: none) and (pointer: coarse)');
157const VENDOR_V = 1; // ophogen als de bestanden in /assets/js/vendor wijzigen
158
159let lenis = null;
160let snap = null;
161
162async function startLenis() {
163 if (lenis || !(OP_DESKTOP.matches || OP_TOUCH.matches)) return;
164 const [L, S] = await Promise.all([
165 import(`/assets/vendor/lenis.mjs?v=${VENDOR_V}`),
166 import(`/assets/vendor/lenis-snap.mjs?v=${VENDOR_V}`),
167 ]);
168 lenis = new L.default({
169 // Lenis tekent de scrollbeweging zelf, maar STEVIG GEDEMPT (lerp 0.2 in
170 // plaats van de standaard 0.1). Reden: een muiswiel scrollt in schokken en
171 // heeft die demping nodig; een Mac-trackpad heeft zijn EIGEN momentum en
172 // krijgt er dan een tweede overheen -- dat is precies het drijverige gevoel
173 // waar Robin voor waarschuwde. Hoger betekent korter naijlen, dus dit is de
174 // middenweg: de schokjes weg, de nasleep kort.
175 // Staat het toch te zweven, dan is lerp omhoog (richting 1) of terug naar
176 // smoothWheel:false de knop -- die stand werkte ook, met alleen de snap.
177 smoothWheel: true,
178 lerp: 0.2,
179 // Alleen op touch overneemt hij het vingerscrollen; op desktop hoeft dat niet.
180 syncTouch: OP_TOUCH.matches,
181 // TOUCH-SPECIFIEK, want de standaardwaarden voelen op een telefoon traag.
182 //
183 // Waar dat zit is precies aan te wijzen. In Lenis staat
184 // `lerp: d ? syncTouchLerp : 1`, waarbij d "de vinger is net losgelaten"
185 // betekent. Tijdens het SLEPEN is de lerp dus 1 -- de inhoud volgt je vinger
186 // exact, net als iOS, en daar is niets mis mee. De traagheid zit in de glijder
187 // NA het loslaten: die gebruikt syncTouchLerp, en dat staat standaard op
188 // 0.075. Dat dempt zo langzaam uit dat de pagina nog seconden naijlt.
189 //
190 // TWEE KNOPPEN, en ze doen iets anders -- dat verwarde ik eerst:
191 //
192 // touchInertiaMultiplier hoe VER een veeg je brengt
193 // syncTouchLerp hoe snel de glijder UITDEMPT
194 //
195 // Eerste ronde zette ik de multiplier op 25 (korter dan de standaard 35),
196 // omdat ik "te traag" las als "hij ijlt te lang na". Robin bedoelde het
197 // omgekeerde: een veeg moet je VERDER brengen, zoals op iOS -- daar draagt
198 // een flick een heel eind. Dus juist omhoog, ruim boven de standaard.
199 //
200 // De lerp gaat mee terug naar 0.1: tussen de trage standaard (0.075, dat
201 // seconden naijlt) en de kordate 0.15 in. Met een langere weg af te leggen
202 // mag de demping wat zachter, anders komt hij te abrupt tot stilstand.
203 // Waar we na een paar rondes proberen op uitkwamen, met de betekenis erbij
204 // omdat de tweede knop contra-intuitief is:
205 //
206 // touchInertiaMultiplier 70 hoe VER een veeg draagt (standaard 35).
207 // 25 en 45 waren allebei te kort.
208 // syncTouchLerp 0.05 hoe snel hij zijn doel BENADERT. Hoger = eerder
209 // aankomen en dus abrupt stoppen; LAGER = langer
210 // onderweg blijven. Robin: "te stroef bij het
211 // loslaten, mag echt een tijdje doorscrollen".
212 // Dus omlaag, niet omhoog -- 0.15 en 0.1 kapten
213 // de uitloop af.
214 // syncTouchLerp 0.25: VIER KEER de vorige waarde, en ruim boven de standaard
215 // van 0.075. Ik heb hem vier rondes lang de verkeerde kant op gedraaid omdat
216 // ik "stroef" las als "hij stopt te vroeg" -- maar het betekende STROPERIG.
217 // Een lage lerp benadert het doel langzaam en geeft dus een kruipende
218 // uitloop; hoog betekent er vlot naartoe. Met de multiplier op 70 blijft de
219 // AFSTAND groot, dus je gaat ver EN snel: dat is hoe een flick op iOS voelt.
220 //
221 // Kort samengevat, want dit is twee keer misgegaan:
222 // syncTouchLerp hoger = sneller weg, korter narollen
223 // multiplier hoger = verder komen
224 ...(OP_TOUCH.matches ? { syncTouchLerp: 0.25, touchInertiaMultiplier: 70 } : {}),
225 autoRaf: true,
226 });
227 snap = new S.default(lenis, {
228 type: 'proximity',
229 distanceThreshold: '12%', // de vangzone, hier WEL instelbaar
230 // Hoe lang na de laatste scrollbeweging hij mag vangen. De standaard is 500
231 // en dat voelt als te laat. Op touch nog korter dan op desktop, want daar
232 // eindigt een veeg in een lange, trage staart -- en juist dan wil je dat de
233 // snap er snel bij is in plaats van te wachten tot de laatste pixel stil ligt.
234 //
235 // LET OP DE SPANNING: een vloeiendere uitloop (lagere syncTouchLerp) maakt
236 // die staart langer, en stelt de snap dus uit. Deze twee getallen houden
237 // elkaar in evenwicht; draai je aan de een, kijk dan ook naar de ander.
238 // OP TOUCH JUIST LANG, en dat is het omgekeerde van wat ik vorige ronde deed.
239 // Tussen je vinger loslaten en het op gang komen van de uitloop zit een korte
240 // stilte in de scrollbeweging. Met 25ms viel de snap precies in dat gat: hij
241 // greep op het moment van loslaten en knipte de veeg af -- wat aanvoelt als
242 // "stroef bij het loslaten", en het werd er dan ook erger van. 200ms laat de
243 // uitloop eerst zijn werk doen.
244 debounce: OP_TOUCH.matches ? 200 : 60,
245 // Op touch korter: een telefoon vraagt om directer antwoord dan een muis, en
246 // de snap komt daar aan het eind van een lange uitloop -- dan mag hij kort.
247 duration: OP_TOUCH.matches ? 0.18 : 0.4,
248 // Vlot weg, dan steeds langzamer aankomen (Robin, 20-8). easeOutQuart: op de
249 // helft van de tijd is 94% van de weg af, en de rest dempt zacht uit.
250 // Bewust NIET Lenis' standaard easeOutExpo -- die schiet weg en kruipt dan
251 // zo lang na dat het lijkt of hij niet afmaakt.
252 easing: (t) => 1 - Math.pow(1 - t, 4),
253 });
254 // Het snappunt is de bovenkant van elk bericht. Het laatste doet niet mee:
255 // zijn bovenkant is niet te bereiken, er zit te weinig pagina onder.
256 const berichten = [...document.querySelectorAll('.feed-reader .read-post')];
257 berichten.slice(0, -1).forEach((a) => snap.addElement(a, { align: 'start' }));
258 // Native snappen uit: twee mechanismen op dezelfde scroller vechten.
259 document.documentElement.style.scrollSnapType = 'none';
260}
261
262function stopLenis() {
263 if (snap) { snap.destroy(); snap = null; }
264 if (lenis) { lenis.destroy(); lenis = null; }
265 document.documentElement.style.scrollSnapType = '';
266}
267
268/**
269 * Omhoog niet snappen. Met Lenis is dat snap.stop()/start(); zonder Lenis (dus
270 * op touch) zetten we de CSS-eigenschap om, precies zoals hiervoor.
271 */
272function zetSnappen(aan) {
273 if (snap) { if (aan) snap.start(); else snap.stop(); return; }
274 const el = document.documentElement;
275 const wil = aan ? '' : 'none';
276 if (el.style.scrollSnapType !== wil) el.style.scrollSnapType = wil;
277}
278
279/** Een richting geldt alleen als er NIETS beweegt. Anders negeren we hem. */
280function nieuwGebaar(naarBeneden) {
281 if (bezig) return;
282 bezig = true;
283 zetSnappen(naarBeneden);
284}
285
286/**
287 * Het gebaar is pas voorbij als de SCROLL stil is, niet als de vinger loslaat:
288 * op iOS loopt de uitloop daarna nog door. `scrollend` zegt dat precies, maar
289 * bestaat niet overal (Chrome 114+, Safari 17+) -- vandaar ook de timer.
290 */
291function rustNu() { bezig = false; }
292function planRust() {
293 clearTimeout(rustTimer);
294 rustTimer = setTimeout(rustNu, RUST_MS);
295}
296
297function opWiel(e) { if (Math.abs(e.deltaY) > 1) nieuwGebaar(e.deltaY > 0); }
298let raakY = 0;
299function opRaakStart(e) { if (e.touches && e.touches[0]) raakY = e.touches[0].clientY; }
300function opRaakBeweeg(e) {
301 if (!e.touches || !e.touches[0]) return;
302 const y = e.touches[0].clientY;
303 // Vinger omhoog = inhoud omlaag. Drie pixels speling tegen de trilling van een
304 // duim die stilstaat.
305 if (Math.abs(y - raakY) > 3) { nieuwGebaar(y < raakY); raakY = y; }
306}
307function opToets(e) {
308 if (['ArrowDown', 'PageDown', 'End', ' ', 'Spacebar'].indexOf(e.key) >= 0) nieuwGebaar(true);
309 else if (['ArrowUp', 'PageUp', 'Home'].indexOf(e.key) >= 0) nieuwGebaar(false);
310}
311
312let knop = null;
313let opScroll = null;
314
315export function init() {
316 const s = document.getElementById('read-stream');
317 if (!s) return;
318 // Op de stroom, niet per artikel: wat "meer laden" erbij zet doet vanzelf mee.
319 // init() draait bij ELKE paginawissel, dus eerst losmaken -- anders stapelt
320 // dezelfde afhandelaar zich op en vuurt hij twee keer.
321 s.removeEventListener('pointerdown', onPointerDown);
322 s.removeEventListener('click', onTap);
323 s.addEventListener('pointerdown', onPointerDown, { passive: true });
324 s.addEventListener('click', onTap);
325
326 // De knop staat in de HTML, zodat hij er ook is zonder deze module -- dan doet
327 // hij niets, maar hij springt niet in beeld bij het laden.
328 knop = document.getElementById('read-top');
329 if (knop) {
330 knop.onclick = naarBoven;
331 toonKnop(knop);
332 }
333
334 if (opScroll) {
335 window.removeEventListener('scroll', opScroll);
336 window.removeEventListener('scrollend', rustNu);
337 window.removeEventListener('resize', opScroll);
338 window.removeEventListener('wheel', opWiel);
339 window.removeEventListener('touchstart', opRaakStart);
340 window.removeEventListener('touchmove', opRaakBeweeg);
341 window.removeEventListener('keydown', opToets);
342 }
343 // Elke scroll -- van een vinger of van de browser zelf -- houdt het gebaar
344 // levend; pas als het stil blijft mag een nieuwe richting gelden.
345 opScroll = () => { if (knop) toonKnop(knop); planRust(); };
346 window.addEventListener('scroll', opScroll, { passive: true });
347 window.addEventListener('scrollend', rustNu);
348 window.addEventListener('resize', opScroll, { passive: true });
349 window.addEventListener('wheel', opWiel, { passive: true });
350 window.addEventListener('touchstart', opRaakStart, { passive: true });
351 window.addEventListener('touchmove', opRaakBeweeg, { passive: true });
352 window.addEventListener('keydown', opToets);
353
354 // Alleen in de leesweergave, en alleen op desktop. Bij elke init() opnieuw
355 // beoordelen: van Grid naar Lezen schakelen hoort hem aan te zetten, en
356 // wegnavigeren hoort hem op te ruimen.
357 stopLenis();
358 if (document.body.dataset.feedView === 'reader') startLenis();
359}
360
361export default { init };
Note: See TracBrowser for help on using the repository browser.