source: Klonkt/src/assets/js/mod/read.js@ 2d2fd8c

main
Last change on this file since 2d2fd8c was 2d2fd8c, checked in by Bart <bart@…>, 3 weeks ago

Tijdlijn | Grid | Lezen: de leesweergave krijgt een ingang

De leesweergave bestond wel maar was alleen te bereiken door /read in te tikken.
Nu staat ze als derde optie in dezelfde pil.

Een <a>, geen <button>, en met opzet NIET de klasse view-switch-btn: de
gedelegeerde klik in shell.ejs pakt alles met die klasse op, zet
body[data-feed-view] erop en springt op niet-feedpagina's terug naar home. Die
had deze link opgegeten.

Door er een link naartoe te leggen kwamen twee fouten boven die er al zaten:

  1. De route gaf ELKE htmx-aanvraag één kaal artikel terug, want de leesmodule had ?partial=1 geleend -- en dat betekent op deze site al iets anders: "de pagina zonder de schil". Een gebooste link naar /read schoof dus één artikel in #pcms-main: geen stroom, geen script. De module vraagt nu ?fragment=1, en al het andere gaat naar pages/read waar renderPage zelf over de schil beslist.
  1. PAGE_CLASSES in shell.ejs is een witte lijst, en 'on-read' stond er niet in. Bij een htmx-navigatie werd de class dus weggefilterd en sloeg ELKE regel van de leesweergave (.on-read ...) niet aan: geen schermhoge berichten, geen snappen, geen wegschuivende balken. Bij intikken ging het goed, want dan zet de shell de class zelf -- vandaar dat het onzichtbaar bleef.

Beide nagelopen in de browser: de pil navigeert, on-read staat er, min-height is
de schermhoogte, snappen staat aan, en de opgeslagen feed-view blijft ongemoeid.

MOD_V naar 3 (read.js) en style.css naar v68 (nieuwe selector).

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)}?fragment=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.