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

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

Lezen: het snappen terug naar CSS, en alleen schakelen bij stilstand

Robins bezwaar (20-8): "scroll-snap op het element is iets anders dan touch
events, requestAnimationFrame etc. dan gaan we te veel van de view doen."
Dat klopte. Ik had een scroll-engine in JavaScript gebouwd bovenop een
CSS-functie -- vangzones uitrekenen, snappunten per bericht armeren, de
richting afleiden uit scrollposities -- en dat vecht met de browser in
plaats van hem te gebruiken.

Weg dus: 111 regels snap-logica eruit. Het snappen doet CSS, met
scroll-snap-type:y proximity en scroll-snap-align:start. De bovenkant van
bericht N+1 IS de onderkant van bericht N, dus dat is precies "grijp de
onderkant en snap naar de volgende post".

Nagemeten wat proximity uit zichzelf doet, en dat bleek genoeg:

10, 30, 60, 100, 150, 200px voor de grens -> snapt
300, 400px -> blijft staan

Ongeveer 200-300px vangzone, dicht bij de 150 die ik met de hand nabouwde.
Die hele berekening was overbodig.

WAT ER IN JAVASCRIPT BLIJFT is één schakelaar: scroll-snap-type aan of uit
op de scroller, want omhoog scrollen moet vrij blijven en CSS kent geen
richtingsgevoelig snappen. De richting komt uit de INVOER (wiel, vinger,
toets) en niet uit scroll-events -- die bevatten ook de bewegingen van de
browser zelf, en zijn snap-animatie gaat soms omhoog. Wie daaruit de
richting afleidt leest de browser en niet de gebruiker.

EN ER WORDT ALLEEN GESCHAKELD BIJ STILSTAND. Dat was de tweede vondst, na
Robins melding dat het snappen soms oversloeg en niet op de bovenkant
landde. Gemeten:

snappen UIT, beweging naar 1459 loopt
-> halverwege snappen AAN gezet
-> eindigt op 1459, NIET op een berichtgrens

De browser raadpleegt scroll-snap-type alleen aan het eind van een gebaar
of van de uitloop; zet je hem daar net vóór of ná om, dan krijg je geen
snap of een snap op het verkeerde moment. En de oude code schakelde bij
ELK wiel-event en ELKE vingerbeweging. Bij stilstand omzetten is wel
onschuldig -- dezelfde proef gaf 0px sprong.

Nu dus: richting bepalen bij het BEGIN van een gebaar, daarna niets meer
aanraken tot de scroll stil is (scrollend, met een timer van 120ms voor
browsers die dat event niet kennen; op iOS loopt de uitloop door ná
touchend, dus loslaten is niet hetzelfde als klaar).

Gemeten na de wijziging: omkeren tijdens een gebaar wordt genegeerd,
landing 80px voor de grens komt exact op 1709 uit, en een nieuw gebaar
omhoog zet het snappen uit.

Ook geprobeerd en verworpen: scroll-snap-type:y mandatory zou "direct
snappen" geven, maar trok in een lang bericht 273px terug -- precies de
val die er net uit was.

scroll-behavior:smooth is van de scroller af. Dat maakte elke beweging
traag, ook het snappen; de browser animeert een snap uit zichzelf al en
korter. De terug-naar-boven-knop vraagt zijn animatie expliciet aan en
blijft dus vloeiend, met zijn twee stappen: eerst de top van dit bericht,
dan de kop van de pagina.

style.css naar v78, MOD_V naar 16.

Suite 1169/1169.

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

  • Property mode set to 100644
File size: 8.5 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
119function zetSnappen(aan) {
120 const el = document.documentElement;
121 const wil = aan ? '' : 'none';
122 if (el.style.scrollSnapType !== wil) el.style.scrollSnapType = wil;
123}
124
125/** Een richting geldt alleen als er NIETS beweegt. Anders negeren we hem. */
126function nieuwGebaar(naarBeneden) {
127 if (bezig) return;
128 bezig = true;
129 zetSnappen(naarBeneden);
130}
131
132/**
133 * Het gebaar is pas voorbij als de SCROLL stil is, niet als de vinger loslaat:
134 * op iOS loopt de uitloop daarna nog door. `scrollend` zegt dat precies, maar
135 * bestaat niet overal (Chrome 114+, Safari 17+) -- vandaar ook de timer.
136 */
137function rustNu() { bezig = false; }
138function planRust() {
139 clearTimeout(rustTimer);
140 rustTimer = setTimeout(rustNu, RUST_MS);
141}
142
143function opWiel(e) { if (Math.abs(e.deltaY) > 1) nieuwGebaar(e.deltaY > 0); }
144let raakY = 0;
145function opRaakStart(e) { if (e.touches && e.touches[0]) raakY = e.touches[0].clientY; }
146function opRaakBeweeg(e) {
147 if (!e.touches || !e.touches[0]) return;
148 const y = e.touches[0].clientY;
149 // Vinger omhoog = inhoud omlaag. Drie pixels speling tegen de trilling van een
150 // duim die stilstaat.
151 if (Math.abs(y - raakY) > 3) { nieuwGebaar(y < raakY); raakY = y; }
152}
153function opToets(e) {
154 if (['ArrowDown', 'PageDown', 'End', ' ', 'Spacebar'].indexOf(e.key) >= 0) nieuwGebaar(true);
155 else if (['ArrowUp', 'PageUp', 'Home'].indexOf(e.key) >= 0) nieuwGebaar(false);
156}
157
158let knop = null;
159let opScroll = null;
160
161export function init() {
162 const s = document.getElementById('read-stream');
163 if (!s) return;
164 // Op de stroom, niet per artikel: wat "meer laden" erbij zet doet vanzelf mee.
165 // init() draait bij ELKE paginawissel, dus eerst losmaken -- anders stapelt
166 // dezelfde afhandelaar zich op en vuurt hij twee keer.
167 s.removeEventListener('pointerdown', onPointerDown);
168 s.removeEventListener('click', onTap);
169 s.addEventListener('pointerdown', onPointerDown, { passive: true });
170 s.addEventListener('click', onTap);
171
172 // De knop staat in de HTML, zodat hij er ook is zonder deze module -- dan doet
173 // hij niets, maar hij springt niet in beeld bij het laden.
174 knop = document.getElementById('read-top');
175 if (knop) {
176 knop.onclick = naarBoven;
177 toonKnop(knop);
178 }
179
180 if (opScroll) {
181 window.removeEventListener('scroll', opScroll);
182 window.removeEventListener('scrollend', rustNu);
183 window.removeEventListener('resize', opScroll);
184 window.removeEventListener('wheel', opWiel);
185 window.removeEventListener('touchstart', opRaakStart);
186 window.removeEventListener('touchmove', opRaakBeweeg);
187 window.removeEventListener('keydown', opToets);
188 }
189 // Elke scroll -- van een vinger of van de browser zelf -- houdt het gebaar
190 // levend; pas als het stil blijft mag een nieuwe richting gelden.
191 opScroll = () => { if (knop) toonKnop(knop); planRust(); };
192 window.addEventListener('scroll', opScroll, { passive: true });
193 window.addEventListener('scrollend', rustNu);
194 window.addEventListener('resize', opScroll, { passive: true });
195 window.addEventListener('wheel', opWiel, { passive: true });
196 window.addEventListener('touchstart', opRaakStart, { passive: true });
197 window.addEventListener('touchmove', opRaakBeweeg, { passive: true });
198 window.addEventListener('keydown', opToets);
199}
200
201export default { init };
Note: See TracBrowser for help on using the repository browser.