@openbooknetwork/kitap 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/app.js ADDED
@@ -0,0 +1,254 @@
1
+ // The story page. The build writes this file after common.js into dist/kitap.js. The page defines ROUTE, the
2
+ // language and page its URL names, and story.js defines BUNDLE. The engine's script, loaded before this one, sets
3
+ // window.KITAP_ENGINE, which the shell mounts into the viewport with a host object once the page is drawn.
4
+
5
+ // ---------- state ----------
6
+ const B = BUNDLE;
7
+ const ICONS = B.icons;
8
+ const PAGES = B.pages;
9
+ const ENGINE = B.engine;
10
+ // cur -1 is the overview: the story's title, span and summary, with the viewport as the engine opens it
11
+ let cur = -1;
12
+ const OVERVIEW = { id: null, overview: true, date: B.span, title: B.title, html: {}, sources: {}, credits: {}, card: null, [ENGINE]: B[ENGINE]?.overview ?? null };
13
+ const here = () => cur < 0 ? OVERVIEW : PAGES[cur];
14
+ let lang = preferredLang(B.languages, B.defaultLanguage);
15
+ let palette = pref('palette', B.themes.map(t => t.id), B.defaultTheme);
16
+ document.documentElement.dataset.palette = palette;
17
+ let layout = pref('layout', LAYOUTS, 'center');
18
+ document.documentElement.dataset.layout = layout;
19
+ const T = () => B.ui[lang];
20
+ const L = field => pick(field, lang, B.defaultLanguage);
21
+
22
+ // ---------- routing: <lang>/<page>/ and the overview <lang>/ under the story folder ----------
23
+ const pageIndex = id => PAGES.findIndex(p => p.id === id);
24
+ const pageLink = i => i < 0 ? folder(lang) : folder(lang, PAGES[i].id);
25
+ const writeRoute = push => setRoute(cur < 0 ? [lang] : [lang, PAGES[cur].id], push);
26
+ // the page the URL names; the bare story folder has no ROUTE and opens the overview in the reader's language
27
+ if (ROUTE) { lang = ROUTE.lang; if (ROUTE.page) cur = pageIndex(ROUTE.page); }
28
+ const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
29
+ // a family's colour token, as the build wrote it for every family the engine's bundle names
30
+ const colorVar = family => family ? `--z-${family}` : '--accent';
31
+ const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
32
+ const canHover = matchMedia('(hover: hover)');
33
+ const phone = matchMedia('(max-width:900px)');
34
+
35
+ // ---------- images ----------
36
+ function openImage(id){
37
+ const im = B.images[id];
38
+ $('lb-img').src = im.src; $('lb-img').alt = L(im.caption);
39
+ $('lb-cap').innerHTML = `${esc(L(im.caption))}<small>${esc(L(im.credit))}</small>`;
40
+ $('lightbox').hidden = false; $('lb-close').focus();
41
+ }
42
+ function closeImage(){ $('lightbox').hidden = true; }
43
+
44
+ // ---------- panes ----------
45
+ const navEl = $('nav'), story = $('story'), legend = $('legend');
46
+ // phones show either the viewport or the text; every step lands on the viewport, with a dot on Text until it is read
47
+ // the text opens at the top the first time it is shown for a page; going back and forth on the same page keeps the place
48
+ let textPage = -1;
49
+ function setTab(tab){
50
+ document.body.dataset.tab = tab;
51
+ $('tab-map').setAttribute('aria-selected', tab === 'map'); $('tab-text').setAttribute('aria-selected', tab === 'text');
52
+ if (tab === 'text' && textPage !== cur) { textPage = cur; story.scrollTop = 0; requestAnimationFrame(() => { story.scrollTop = 0; }); }
53
+ }
54
+ function renderChrome(){
55
+ $('tab-map').textContent = T().tab_map; $('tab-text').textContent = T().tab_text;
56
+ document.documentElement.lang = lang; document.documentElement.dir = T().dir === 'rtl' ? 'rtl' : 'ltr';
57
+ $('title').textContent = L(B.title);
58
+ $('back').textContent = L(B.site.title);
59
+ $('back').href = siteLink(lang);
60
+ $('hint').textContent = T().hint;
61
+ if (B.site.source) { $('src').href = B.site.source; $('src').innerHTML = `${ICONS['external-link']} ${esc(T().story_source)}`; $('src').hidden = false; }
62
+ $('made').innerHTML = credit(T());
63
+ $('mapdata').innerHTML = `${ICONS.info}<span>${esc(T().map_data)}</span>`; $('mapdata').setAttribute('aria-label', T().map_data);
64
+ $('mapdata').hidden = !B.credits.length;
65
+ $('othersrc').innerHTML = `${ICONS['external-link']}<span>${esc(T().other_sources)}</span>`; $('othersrc').setAttribute('aria-label', T().other_sources);
66
+ $('credits').innerHTML = `<p class="made">${credit(T())}</p><h4>${esc(T().map_data)}</h4><ul>${B.credits.map(c => `<li>${esc(L(c))}</li>`).join('')}</ul>`;
67
+ $('prev').innerHTML = `${ICONS['chevron-left']} ${esc(T().prev)}`;
68
+ $('next').innerHTML = `${esc(T().next)} ${ICONS['chevron-right']}`;
69
+ $('lb-close').textContent = T().close;
70
+ navEl.setAttribute('aria-label', T().chronology);
71
+ fillSelect($('lang'), B.languages.map(l => ({ value: l, text: B.ui[l].name ?? l })), lang, T().language);
72
+ $('lang-icon').innerHTML = ICONS.languages; $('langpick').title = T().language; $('langpick').hidden = B.languages.length < 2;
73
+ fillSelect($('theme'), B.themes.map(t => ({ value: t.id, text: L(t.name) })), palette, T().theme);
74
+ $('settings-btn').innerHTML = `${ICONS.settings} ${esc(T().settings)}`;
75
+ $('settings').setAttribute('aria-label', T().settings);
76
+ $('layout-label').textContent = T().layout; $('theme-label').textContent = T().theme;
77
+ $('layouts').setAttribute('aria-label', T().layout);
78
+ $('layouts').innerHTML = layoutButtons(T(), layout);
79
+ $('legend-btn').textContent = T().legend;
80
+ }
81
+ function setLayout(l){
82
+ layout = l; saveLayout(l);
83
+ // the viewport has changed size, so the engine frames the page again
84
+ V.resize?.();
85
+ }
86
+ const closedGroups = new Set(); // groups the reader folded; navigating to a page opens the groups above it
87
+ const holds = (node, index) => node.type === 'page' ? node.index === index : node.children.some(c => holds(c, index));
88
+ const groupKeys = (nodes, key = '') => nodes.flatMap(n => n.type === 'group' ? [key + '/' + n.id, ...groupKeys(n.children, key + '/' + n.id)] : []);
89
+ const ALL_GROUPS = groupKeys(B.tree);
90
+ const allGroupsOpen = () => ALL_GROUPS.every(k => !closedGroups.has(k));
91
+ function renderNav(reveal = true){
92
+ navEl.innerHTML = '';
93
+ const head = document.createElement('div'); head.className = 'sheet-head';
94
+ head.innerHTML = `<h2>${esc(T().contents)}</h2><button type="button" class="sheet-close">${esc(T().close)}</button>`;
95
+ head.querySelector('button').onclick = closeSheet;
96
+ navEl.appendChild(head);
97
+ if (ALL_GROUPS.length) {
98
+ const t = document.createElement('button'); t.type = 'button'; t.className = 'toggle-all';
99
+ t.textContent = toggleLabel(T(), allGroupsOpen());
100
+ t.onclick = () => { allGroupsOpen() ? ALL_GROUPS.forEach(k => closedGroups.add(k)) : closedGroups.clear(); renderNav(false); };
101
+ navEl.appendChild(t);
102
+ }
103
+ const build = (nodes, depth, key) => {
104
+ const frag = document.createDocumentFragment();
105
+ for (const n of nodes) {
106
+ if (n.type === 'page') {
107
+ const p = PAGES[n.index]; const a = document.createElement('a');
108
+ a.className = 'pg'; a.href = pageLink(n.index); a.dataset.page = n.index;
109
+ a.style.setProperty('--depth', depth); if (n.index === cur) a.setAttribute('aria-current', 'page');
110
+ a.innerHTML = `${L(p.date) ? `<span class="d">${esc(L(p.date))}</span>` : ''}${esc(L(p.title))}`; frag.appendChild(a);
111
+ } else {
112
+ const k = key + '/' + n.id; const d = document.createElement('details');
113
+ if (reveal && holds(n, cur)) closedGroups.delete(k);
114
+ d.open = !closedGroups.has(k);
115
+ d.addEventListener('toggle', () => { if (d.open) closedGroups.delete(k); else closedGroups.add(k); const btn = navEl.querySelector('.toggle-all'); if (btn) btn.textContent = toggleLabel(T(), allGroupsOpen()); });
116
+ const s = document.createElement('summary'); s.style.setProperty('--depth', depth); s.textContent = L(n.title);
117
+ d.appendChild(s); d.appendChild(build(n.children, depth + 1, k)); frag.appendChild(d);
118
+ }
119
+ }
120
+ return frag;
121
+ };
122
+ navEl.appendChild(build(B.tree, 0, ''));
123
+ }
124
+ // phones: the group path to the current page, and the page itself
125
+ const pathTo = (nodes, index) => { for (const n of nodes) { if (n.type === 'page' && n.index === index) return []; if (n.type === 'group') { const r = pathTo(n.children, index); if (r) return [n, ...r]; } } return null; };
126
+ function renderWhere(p){
127
+ $('crumbs').textContent = (pathTo(B.tree, cur) ?? []).map(g => L(g.title)).join(' › ');
128
+ $('here').innerHTML = p === OVERVIEW ? `<small>${esc(L(B.span))}</small>${esc(L(B.title))}` : `<small>${esc(L(p.date))}</small>${esc(L(p.title))}`;
129
+ $('contents').innerHTML = `${ICONS.list} ${esc(T().contents)}`;
130
+ }
131
+ function openSheet(){ document.body.classList.add('sheet'); requestAnimationFrame(() => { const b = navEl.querySelector('.pg[aria-current="page"]'); if (b) b.scrollIntoView({ block: 'center' }); }); }
132
+ function closeSheet(){ document.body.classList.remove('sheet'); }
133
+ // the padding an engine keeps clear round the page's frame: on a phone the peek card covers the bottom of the viewport
134
+ function fitPadding(){
135
+ const peek = $('peek'), pad = 40;
136
+ return phone.matches && !peek.hidden ? { top: pad, left: pad, right: pad, bottom: peek.offsetHeight + 24 } : pad;
137
+ }
138
+ function renderPeek(){
139
+ const first = story.querySelector('p');
140
+ $('peek-text').textContent = first ? first.textContent : '';
141
+ $('peek-more').textContent = T().read_more;
142
+ $('peek-close').setAttribute('aria-label', T().hide);
143
+ $('peek').hidden = !first;
144
+ }
145
+ // credits this page's picture carries beyond the story's, behind an "Other sources" button beside the data credit
146
+ function renderCredits(p){
147
+ const list = p.credits?.[lang] ?? [];
148
+ $('othersrc').hidden = !list.length; $('othercredits').hidden = true; $('othersrc').setAttribute('aria-expanded', false);
149
+ $('othercredits').innerHTML = list.length ? `<h4>${esc(T().other_sources)}</h4><ul>${list.map(s => `<li>${s.url
150
+ ? `<a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(s.text)}</a>` : esc(s.text)}</li>`).join('')}</ul>` : '';
151
+ }
152
+ function renderStory(p){
153
+ renderWhere(p);
154
+ if (p === OVERVIEW) {
155
+ story.innerHTML = `<div class="date">${esc(L(B.span))}</div><h2>${esc(L(B.title))}</h2>${B.summary ? `<p>${esc(L(B.summary))}</p>` : ''}`
156
+ + `<p><a class="start" href="${esc(pageLink(0))}" data-page="0">${esc(T().start)} ${ICONS['chevron-right']}</a></p>`;
157
+ document.title = L(B.title);
158
+ } else {
159
+ const sources = p.sources[lang].length ? `<h3>${esc(T().sources)}</h3><ol>${p.sources[lang].map(x => `<li>${esc(x)}</li>`).join('')}</ol>` : '';
160
+ story.innerHTML = `${L(p.date) ? `<div class="date">${esc(L(p.date))}</div>` : ''}<h2>${esc(L(p.title))}</h2>${p.html[lang]}${sources}`;
161
+ document.title = `${L(p.title)} - ${L(B.title)}`;
162
+ }
163
+ renderCredits(p);
164
+ story.scrollTop = 0;
165
+ legend.hidden = true; $('legend-btn').hidden = true; // the engine fills the legend and shows the button for a page that has one
166
+ renderPeek();
167
+ $('stepno').textContent = cur < 0 ? '' : `${cur + 1} / ${PAGES.length}`;
168
+ $('prev').disabled = cur < 0; $('next').disabled = cur === PAGES.length - 1;
169
+ }
170
+ // reveal centres the current page in the contents; a click in the contents itself leaves the scroll alone
171
+ function go(i, fromRoute, reveal = true){ cur = i; closeImage(); setTab('map'); document.body.classList.remove('legend-open'); renderNav(); if (reveal) revealCurrent(); renderStory(here()); V.show?.(here(), false); if (!fromRoute) writeRoute(true); }
172
+ function revealCurrent(){
173
+ const b = navEl.querySelector('.pg[aria-current="page"]'); if (!b) return;
174
+ const n = navEl.getBoundingClientRect(), r = b.getBoundingClientRect();
175
+ navEl.scrollTo({ top: navEl.scrollTop + r.top - n.top - (n.height - r.height) / 2, left: navEl.scrollLeft + r.left - n.left - (n.width - r.width) / 2, behavior: reduced ? 'auto' : 'smooth' });
176
+ }
177
+ function setLang(l, fromRoute){
178
+ lang = l; savePref('lang', l);
179
+ renderChrome(); renderNav(); renderStory(here()); V.lang?.(l); if (!fromRoute) writeRoute(false);
180
+ }
181
+ function setPalette(id){ palette = id; applyPalette(id); $('theme').value = id; V.theme?.(); }
182
+ // the back and forward buttons: the path names a language and a page, or a language alone for the overview
183
+ addEventListener('popstate', () => {
184
+ const [l, page] = pathParts(), i = page ? pageIndex(page) : -1;
185
+ if (B.languages.includes(l) && l !== lang) setLang(l, true);
186
+ if (i !== cur && (i >= 0 || !page)) go(i, true);
187
+ });
188
+ // links to a page step to it in place; a modified click opens the link as the browser does
189
+ document.addEventListener('click', e => {
190
+ const a = e.target.closest('a[data-page]');
191
+ if (!a || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
192
+ e.preventDefault(); const inNav = !!a.closest('#nav'); if (inNav) closeSheet(); go(Number(a.dataset.page), false, !inNav);
193
+ });
194
+ $('contents').onclick = openSheet;
195
+ $('tab-map').onclick = () => setTab('map');
196
+ $('tab-text').onclick = () => setTab('text');
197
+ setTab('map');
198
+ // the peek card: tap anywhere on it for the text; × hides it until the next page
199
+ $('peek').onclick = () => setTab('text');
200
+ $('peek').onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setTab('text'); } };
201
+ $('peek-close').onclick = e => { e.stopPropagation(); $('peek').hidden = true; };
202
+ $('legend-btn').onclick = () => document.body.classList.toggle('legend-open');
203
+ // the data credit popup and the page's other sources popup share the credit bar, one open at a time
204
+ const CREDIT_POPUPS = { mapdata: 'credits', othersrc: 'othercredits' };
205
+ const showCredits = (open, btn = 'mapdata') => { for (const [b, p] of Object.entries(CREDIT_POPUPS)) { const on = open && b === btn; $(p).hidden = !on; $(b).setAttribute('aria-expanded', on); } };
206
+ $('mapdata').onclick = () => showCredits($('credits').hidden, 'mapdata');
207
+ $('othersrc').onclick = () => showCredits($('othercredits').hidden, 'othersrc');
208
+ document.addEventListener('click', e => { if (!e.target.closest('.credit')) showCredits(false); });
209
+ const showSettings = open => { $('settings').hidden = !open; $('settings-btn').setAttribute('aria-expanded', open); };
210
+ $('settings-btn').onclick = () => showSettings($('settings').hidden);
211
+ document.addEventListener('click', e => { if (!$('settings').hidden && !e.target.closest('.prefs')) showSettings(false); });
212
+ $('layouts').onclick = e => { const b = e.target.closest('button[data-layout]'); if (b) setLayout(b.dataset.layout); };
213
+ document.addEventListener('click', e => { const t = e.target.closest('[data-img]'); if (t) { e.stopPropagation(); openImage(t.dataset.img); } });
214
+ document.addEventListener('keydown', e => {
215
+ if (e.key === 'Enter' && e.target.matches('[data-img]')) openImage(e.target.dataset.img);
216
+ if (e.key === 'Escape') { closeImage(); V.escape?.(); closeSheet(); showCredits(false); showSettings(false); }
217
+ if (e.target.closest?.('input,textarea,select')) return;
218
+ if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
219
+ e.preventDefault(); // at either end the key does nothing, rather than scrolling the page or the viewport
220
+ const step = (e.key === 'ArrowRight') === (T().dir !== 'rtl') ? 1 : -1; // right to left text reads forward to the left
221
+ if (cur + step >= -1 && cur + step < PAGES.length) go(cur + step);
222
+ }
223
+ });
224
+ // pointing at a place the text links, or tabbing to it, shows it in the viewport. A phone shows the text or the
225
+ // viewport, so there the places are plain text.
226
+ const placeId = el => el.dataset.marker ?? el.dataset.ref;
227
+ const placeOf = e => phone.matches ? null : e.target.closest?.('.place');
228
+ story.addEventListener('mouseover', e => { const p = placeOf(e); if (p && canHover.matches) V.point?.(placeId(p), true); });
229
+ story.addEventListener('mouseout', e => { const p = placeOf(e); if (p && canHover.matches && !p.contains(e.relatedTarget)) V.point?.(placeId(p), false); });
230
+ story.addEventListener('focusin', e => { const p = placeOf(e); if (p?.matches(':focus-visible')) V.point?.(placeId(p), true); });
231
+ story.addEventListener('focusout', e => { const p = placeOf(e); if (p) V.point?.(placeId(p), false); });
232
+ $('lb-close').onclick = closeImage;
233
+ $('lightbox').addEventListener('click', e => { if (e.target.id === 'lightbox') closeImage(); });
234
+ $('lang').onchange = e => setLang(e.target.value);
235
+ $('theme').onchange = e => setPalette(e.target.value);
236
+ $('prev').onclick = () => go(cur - 1);
237
+ $('next').onclick = () => go(cur + 1);
238
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => V.theme?.());
239
+ new MutationObserver(() => V.theme?.()).observe(document.documentElement, { attributes:true, attributeFilter:['data-theme'] });
240
+
241
+ // ---------- the engine ----------
242
+ // What the engine's client gets: the bundle, the elements it owns inside the stage, the current page and language,
243
+ // and the shell's helpers. It returns the calls the shell makes as the reader moves: show(page, first), lang(l),
244
+ // theme(), resize(), escape(), point(id, on) and highlight(kind, id).
245
+ const host = {
246
+ B, engine: ENGINE, el: { map: $('map'), legend, story, label: $('hl-label'), view: $('view'), tools: document.querySelector('.maptools'), legendButton: $('legend-btn') },
247
+ here, isOverview: () => cur < 0, lang: () => lang, L, T, css, colorVar, esc, $, pref, savePref, reduced, canHover, phone, padding: fitPadding, openImage, closeImage, placeId,
248
+ };
249
+ let V = {};
250
+ renderChrome(); renderNav(); renderStory(here());
251
+ // a classic engine script has set KITAP_ENGINE already; a module one sets it after this script and says so
252
+ const mount = () => { V = window.KITAP_ENGINE(host); V.show?.(here(), true); };
253
+ if (window.KITAP_ENGINE) mount(); else addEventListener('kitap-engine', mount, { once: true });
254
+ writeRoute(false);