source: Klonkt/src/assets/js/mod/read.js@ 03ff677

main
Last change on this file since 03ff677 was 847775c, checked in by Bart <bart@…>, 3 weeks ago

Leesweergave: tikken op een bericht opent het bericht

De stroom toont de tekst, maar het gesprek staat op de berichtpagina: ⭐,
🔁, reacties, en het antwoordveld voor bezoekers van elders. Een leesweergave
waar je niet uit kunt naar dat gesprek is een doodlopende weg.

Drie ingangen, met opzet:

  • de titel is een echte <a>, dus toetsenbord, schermlezer en cmd-klik werken;
  • een voetlink zegt waar je heen gaat ("Reacties en waarderingen"), want een tik die je niet ziet, bestaat niet;
  • en een tik ergens op het bericht, voor een duim.

Die tik kent vier uitzonderingen, want een tik die je niet bedoelde is erger dan
geen tik: links, knoppen en velden houden hun eigen werking, een geselecteerde
tekst is geen tik, een verschoven vinger is scrollen, en cmd/ctrl-klik laat je
aan de browser. Alle vier nagemeten in de browser, plus dat een link ín een
bericht wint van de tik.

MOD_V naar 2: mod/read.js is gewijzigd.

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

  • Property mode set to 100644
File size: 8.6 KB
Line 
1/**
2 * De leesweergave: een stroom van hele berichten, één per beeld.
3 *
4 * NIET de truc van bartoverkamp.nl. Daar springt de pagina bij de rand naar de
5 * andere kant en wordt het nieuwe artikel "vloeiend" in beeld gezet -- een naad
6 * die verborgen wordt. Hier groeit de stroom in de richting waarin je leest:
7 * bij de onderrand komt de oudere buur eronder te staan, bij de bovenrand de
8 * nieuwere erboven. De browser snapt naar de berichtgrenzen (CSS, proximity).
9 * Er is dan geen naad, dus ook niets te verbergen.
10 *
11 * Elk artikel draagt zijn eigen buren in data-attributen. Wat er nog te halen
12 * valt staat dus altijd op het stuk dat er al is, en deze module hoeft geen
13 * lijst bij te houden die uit de pas kan lopen.
14 *
15 * De namen hieronder zijn Engels, zoals overal in assets/js/mod. Het commentaar
16 * blijft Nederlands, zoals overal in deze repo.
17 */
18
19// Hoeveel schermen vooruit we alvast halen. Eén is genoeg: je leest niet
20// sneller dan je scrolt, en meer betekent alleen meer verkeer.
21const ROOT_MARGIN = '1px 0px 100% 0px';
22
23// De teksten die dit script plaatst. Ze staan in data-i18n op de stroom, want
24// een zin die hier staat, staat in één taal -- en dat is nooit de taal van de
25// lezer. Zelfde afspraak als de topnav (mod/chrome.js).
26let strings = {};
27function str(key) { return strings[key] || ''; }
28
29const stream = () => document.getElementById('read-stream');
30const loaded = new Set(); // slugs die al in de stroom staan
31let busyBelow = false;
32let busyAbove = false;
33let observer = null;
34let lastY = 0;
35
36// Niet `chrome`: dat is in deze browser ook een global, en een module-scope die
37// window.chrome overschaduwt is een val voor de volgende lezer.
38function toggleChrome(hide) {
39 document.body.classList.toggle('read-chrome-hidden', !!hide);
40}
41
42/** Het artikel dat nu het meest in beeld is; die bepaalt titel en URL. */
43function inView() {
44 const posts = [...document.querySelectorAll('.read-post')];
45 const middle = window.innerHeight / 2;
46 return posts.find((p) => {
47 const r = p.getBoundingClientRect();
48 return r.top <= middle && r.bottom >= middle;
49 }) || posts[0];
50}
51
52/** Titel en adres volgen wat je leest. replaceState: je bladert, je stapelt niet. */
53function syncAddress() {
54 const p = inView();
55 if (!p) return;
56 const slug = p.dataset.slug;
57 const base = p.dataset.base || '';
58 if (location.pathname === `${base}/read/${slug}`) return;
59 history.replaceState(null, '', `${base}/read/${slug}`);
60 if (p.dataset.title) document.title = p.dataset.title;
61}
62
63async function fetchArticle(slug, base) {
64 const r = await fetch(`${base}/read/${encodeURIComponent(slug)}?partial=1`, {
65 headers: { 'HX-Request': 'true' },
66 credentials: 'same-origin',
67 });
68 if (!r.ok) throw new Error('HTTP ' + r.status);
69 const box = document.createElement('div');
70 box.innerHTML = await r.text();
71 const art = box.querySelector('.read-post');
72 if (!art) throw new Error('no post in the response');
73 return art;
74}
75
76/** Onderaan aanvullen: gewoon erbij zetten, de scrollpositie verandert niet. */
77async function appendOlder() {
78 const s = stream();
79 if (!s || busyBelow) return;
80 const last = s.querySelector('.read-post:last-of-type');
81 const slug = last && last.dataset.older;
82 if (!slug) return markEnd(s);
83 if (loaded.has(slug)) return;
84 busyBelow = true;
85 const pending = notice(s, 'read-loading', '…');
86 try {
87 const art = await fetchArticle(slug, last.dataset.base || '');
88 loaded.add(slug);
89 pending.remove();
90 s.appendChild(art);
91 watch();
92 } catch (e) {
93 pending.textContent = str('load_error');
94 console.warn('[read] below:', e && e.message);
95 } finally { busyBelow = false; }
96}
97
98/**
99 * Bovenaan aanvullen. Dit is het enige plek waar we de scrollpositie WEL
100 * moeten bijstellen: er komt hoogte bij bóven het beeld, en zonder correctie
101 * schuift alles wat je las naar beneden weg. We meten de hoogte na het invoegen
102 * en tellen die bij de scrollpositie op -- geen animatie, geen sprong.
103 */
104async function prependNewer() {
105 const s = stream();
106 if (!s || busyAbove) return;
107 const first = s.querySelector('.read-post');
108 const slug = first && first.dataset.newer;
109 if (!slug || loaded.has(slug)) return;
110 busyAbove = true;
111 const root = document.documentElement;
112 try {
113 const art = await fetchArticle(slug, first.dataset.base || '');
114 loaded.add(slug);
115 // Snappen even UIT. De correctie hieronder zet de scrollpositie precies
116 // goed, maar een proximity-snap trekt hem daarna alsnog naar de grens van
117 // het bericht dat er net bij kwam -- gemeten: een sprong ter grootte van
118 // dat hele bericht, precies de ruk die deze opzet moet vermijden.
119 root.style.scrollSnapType = 'none';
120 const before = s.scrollHeight;
121 s.insertBefore(art, first);
122 const added = s.scrollHeight - before;
123 window.scrollBy(0, added);
124 watch();
125 // Twee frames: één om de correctie te laten landen, één om te snappen pas
126 // weer toe te staan als de browser klaar is met deze scroll.
127 requestAnimationFrame(() => requestAnimationFrame(() => { root.style.scrollSnapType = ''; }));
128 } catch (e) {
129 root.style.scrollSnapType = '';
130 console.warn('[read] above:', e && e.message);
131 } finally { busyAbove = false; }
132}
133
134function notice(s, className, text) {
135 const d = document.createElement('div');
136 d.className = className;
137 d.textContent = text;
138 s.appendChild(d);
139 return d;
140}
141
142function markEnd(s) {
143 if (s.querySelector('.read-end')) return;
144 notice(s, 'read-end', str('end'));
145}
146
147/** (Her)richt de waarnemer op de huidige eerste en laatste post. */
148function watch() {
149 if (observer) observer.disconnect();
150 observer = new IntersectionObserver((entries) => {
151 for (const e of entries) {
152 if (!e.isIntersecting) continue;
153 // GEEN else-if. Bij het laden staat er één artikel in de stroom, en dat is
154 // tegelijk het eerste én het laatste. Met een else-if wint de eerste tak
155 // altijd, en dan draait prependNewer() nooit: wie op het OUDSTE bericht
156 // binnenkomt, krijgt "einde archief" te zien terwijl er zes nieuwere
157 // boven hem staan die hij nooit te pakken krijgt.
158 if (e.target.matches('.read-post:last-of-type')) appendOlder();
159 if (e.target.matches('.read-post:first-of-type')) prependNewer();
160 }
161 }, { rootMargin: ROOT_MARGIN });
162 const first = stream() && stream().querySelector('.read-post');
163 const last = stream() && stream().querySelector('.read-post:last-of-type');
164 if (first) observer.observe(first);
165 if (last && last !== first) observer.observe(last);
166}
167
168/**
169 * Tikken op een bericht opent dat bericht.
170 *
171 * De stroom toont de tekst; het gesprek -- reacties, waarderingen, boosts --
172 * staat op de berichtpagina. De titel en de voetlink zijn echte <a>'s en doen
173 * het werk voor toetsenbord en schermlezer; dit hier is het gemak voor een duim.
174 *
175 * Daarom vier uitzonderingen, want een tik die je niet bedoelde is erger dan
176 * geen tik: iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen
177 * werking, een geselecteerde tekst is geen tik, een verschoven vinger is
178 * scrollen, en cmd/ctrl-klik hoort de browser zelf af te handelen.
179 */
180let tapX = 0, tapY = 0;
181function onPointerDown(e) { tapX = e.clientX; tapY = e.clientY; }
182
183function onTap(e) {
184 if (e.defaultPrevented || e.button !== 0) return;
185 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
186 const t = e.target;
187 if (!t || typeof t.closest !== 'function') return;
188 const art = t.closest('.read-post');
189 if (!art) return;
190 if (t.closest('a, button, input, textarea, select, label, summary, [role="button"]')) return;
191 if (Math.abs(e.clientX - tapX) > 10 || Math.abs(e.clientY - tapY) > 10) return;
192 const sel = window.getSelection && window.getSelection();
193 if (sel && String(sel).trim()) return;
194 const slug = art.dataset.slug;
195 if (!slug) return;
196 location.href = (art.dataset.base || '') + '/' + encodeURIComponent(slug);
197}
198
199function onScroll() {
200 const y = window.scrollY || 0;
201 if (y < 8) toggleChrome(false);
202 else if (y > lastY + 4) toggleChrome(true);
203 else if (y < lastY - 24) toggleChrome(false);
204 lastY = y;
205 syncAddress();
206}
207
208export function init() {
209 const s = stream();
210 if (!s) return;
211 try { strings = JSON.parse(s.getAttribute('data-i18n') || '{}'); } catch (e) { strings = {}; }
212 s.querySelectorAll('.read-post').forEach((p) => loaded.add(p.dataset.slug));
213 lastY = window.scrollY || 0;
214 window.addEventListener('scroll', onScroll, { passive: true });
215 window.addEventListener('resize', watch, { passive: true });
216 // Op de stroom, niet per artikel: aangevulde berichten doen zo vanzelf mee.
217 s.addEventListener('pointerdown', onPointerDown, { passive: true });
218 s.addEventListener('click', onTap);
219 watch();
220}
221
222export default { init };
Note: See TracBrowser for help on using the repository browser.