chrome.js
10.8 kB · javascript · 370 lines
1const DOCK = '(min-width: 74rem)';2const PREFIX = (typeof document !== 'undefined' && document.documentElement.dataset.prefix) || 'mrly-';3const KEY = { theme: `${PREFIX}theme`, font: `${PREFIX}font`, tint: `${PREFIX}tint`, saver: `${PREFIX}saver`, cart: `${PREFIX}cart` };4const WORDMARK = 'wordmark';5const SCREENS = ['matrix', 'sleep', 'mandelbrot', 'julia'];6const SIDES = ['left', 'right'];7const SHADE = 'screen and (prefers-color-scheme: dark)';89const read = (key) => {10 try {11 return localStorage.getItem(key) ?? '';12 } catch {13 return '';14 }15};1617const write = (key, value) => {18 try {19 if (value) localStorage.setItem(key, value);20 else localStorage.removeItem(key);21 } catch {}22};2324/* SUB */2526const root = () => document.documentElement;27const docked = () => matchMedia(DOCK).matches;28const isOpen = (side) => root().dataset[side] === 'open';29const dock = () => document.querySelector('.dock');30const ready = (fn) => (document.readyState === 'complete' ? fn() : addEventListener('load', fn, { once: true }));3132const spot = () => `${PREFIX}spot:${location.href}`;3334function keep() {35 try {36 sessionStorage.setItem(spot(), String(scrollY));37 } catch {}38}3940function saved() {41 try {42 const at = Number(sessionStorage.getItem(spot())) || 0;43 sessionStorage.removeItem(spot());44 return at;45 } catch {46 return 0;47 }48}4950function resumed() {51 const kind = performance.getEntriesByType('navigation')[0]?.type;52 return kind === 'back_forward' || kind === 'reload';53}5455function pin() {56 const top = dock()?.getBoundingClientRect().top ?? 0;57 if (top) scrollTo({ top: top + scrollY, behavior: 'instant' });58}5960function land() {61 const at = saved();62 if (at > 0 && resumed() && !location.hash) scrollTo({ top: at, behavior: 'instant' });63}6465/* DRAWERS */6667function set(side, open) {68 root().dataset[side] = open ? 'open' : 'shut';69 sync();70}7172function stow() {73 for (const side of SIDES) set(side, false);74}7576function sync() {77 for (const button of document.querySelectorAll('[data-pane]')) button.setAttribute('aria-expanded', String(isOpen(button.dataset.pane)));78}7980function toggle(side) {81 const open = !isOpen(side);82 if (open) pin();83 set(side, open);84 if (!open) return;85 set(side === 'left' ? 'right' : 'left', false);86 document.getElementById(side)?.querySelector('a, button')?.focus();87}8889function shut() {90 if (docked()) return false;91 const open = SIDES.filter(isOpen);92 for (const side of open) set(side, false);93 if (open.length) document.querySelector(`[data-pane="${open[0]}"]`)?.focus();94 return open.length > 0;95}9697/* HALVES */9899function halves() {100 const set = root().dataset.theme;101 const media = set === 'dark' ? 'screen' : set === 'light' ? 'not all' : SHADE;102 for (const source of document.querySelectorAll('picture source[data-dark]')) source.media = media;103}104105/* THEME */106107function theme(next) {108 if (next) root().dataset.theme = next;109 else delete root().dataset.theme;110 write(KEY.theme, next);111 for (const label of document.querySelectorAll('[data-theme-toggle] b')) label.textContent = next || 'auto';112 window.dispatchEvent(new Event('theme'));113}114115/* TINT */116117function tint(next) {118 if (next) root().dataset.tint = next;119 else delete root().dataset.tint;120 write(KEY.tint, next);121 for (const pick of document.querySelectorAll('[data-tint-pick]')) pick.value = next || '';122 window.dispatchEvent(new Event('theme'));123}124125function turn() {126 const now = root().dataset.theme ?? '';127 theme(now === '' ? 'light' : now === 'light' ? 'dark' : '');128}129130/* FONT */131132function face(next) {133 if (next) root().dataset.font = next;134 else delete root().dataset.font;135 write(KEY.font, next);136 for (const pick of document.querySelectorAll('[data-font-pick]')) pick.value = next || '';137}138139/* CART */140141function count() {142 try {143 const items = JSON.parse(read(KEY.cart) || '[]');144 return Array.isArray(items) ? items.reduce((sum, item) => sum + (Number(item.qty) || 1), 0) : 0;145 } catch {146 return 0;147 }148}149150function cart() {151 const n = count();152 for (const link of document.querySelectorAll('[data-cart]')) {153 link.setAttribute('aria-label', n ? `Cart, ${n} item${n === 1 ? '' : 's'}` : 'Cart');154 link.querySelectorAll('.dot').forEach((dot, i) => dot.classList.toggle('on', i < n));155 }156}157158/* CONTENTS */159160function contents(nav) {161 const links = [...nav.querySelectorAll('a[href^="#"]')];162 const targets = links.map((a) => document.getElementById(decodeURIComponent(a.hash.slice(1)))).filter(Boolean);163 if (!targets.length) return;164 const seen = new Map();165 const eye = new IntersectionObserver(166 (entries) => {167 for (const entry of entries) seen.set(entry.target, entry.isIntersecting);168 const hit = targets.find((t) => seen.get(t));169 if (!hit) return;170 for (const a of links) {171 if (a.hash.slice(1) === hit.id) a.setAttribute('aria-current', 'location');172 else a.removeAttribute('aria-current');173 }174 },175 { rootMargin: `-${Math.round(dock()?.getBoundingClientRect().height ?? 0)}px 0px -60% 0px` },176 );177 for (const t of targets) eye.observe(t);178}179180/* MARK */181182const marks = new Map();183184const wanted = () => root().dataset.saver || WORDMARK;185186function unmark() {187 for (const canvas of marks.keys()) {188 if (canvas.isConnected) continue;189 canvas.stop?.();190 marks.delete(canvas);191 }192}193194async function footer(canvas) {195 canvas.stop?.();196 marks.delete(canvas);197 const next = canvas.cloneNode(false);198 const label = next.dataset.label ?? next.getAttribute('aria-label') ?? '';199 if (label) next.dataset.label = label;200 canvas.replaceWith(next);201 const name = wanted();202 marks.set(next, name);203 if (name === WORDMARK) {204 next.removeAttribute('aria-hidden');205 next.setAttribute('role', 'img');206 if (label) next.setAttribute('aria-label', label);207 const { cycle, mark } = await import('./font.js');208 next.stop = mark(next, cycle(next.dataset.text || 'MRLYPROD', 1));209 } else {210 next.setAttribute('aria-hidden', 'true');211 next.removeAttribute('role');212 next.removeAttribute('aria-label');213 const { saver } = await import('./savers/index.js');214 next.stop = saver(next, name);215 }216 if (!next.isConnected) next.stop();217}218219function marked() {220 const name = wanted();221 for (const canvas of document.querySelectorAll('canvas.mark')) if (marks.get(canvas) !== name) footer(canvas);222}223224function screen(next) {225 if (SCREENS.includes(next)) root().dataset.saver = next;226 else delete root().dataset.saver;227 const now = root().dataset.saver ?? '';228 write(KEY.saver, now);229 for (const pick of document.querySelectorAll('[data-saver-pick]')) pick.value = now;230}231232function replay(name) {233 screen(name);234 for (const canvas of [...marks.keys()]) footer(canvas);235}236237/* TREE */238239function reveal() {240 const here = document.querySelector('.tree a[aria-current="page"]');241 const pane = here?.closest('.pane');242 if (!here || !pane) return;243 const top = here.getBoundingClientRect().top - pane.getBoundingClientRect().top + pane.scrollTop;244 pane.scrollTop = Math.max(0, top - pane.clientHeight / 2);245}246247/* EXPLORER */248249let forest = null;250251const fetchTree = (url) => (forest ??= fetch(url).then((reply) => (reply.ok ? reply.json() : null)).catch(() => null));252253const named = (path) => (path.slice(path.lastIndexOf('/') + 1).includes('.') ? path : `${path}.txt`);254255function find(node, path) {256 let at = node;257 for (const part of path ? path.split('/') : []) {258 at = (at.c ?? []).find((kid) => kid.n === part);259 if (!at) return null;260 }261 return at;262}263264function branch(base, kid, path) {265 const li = document.createElement('li');266 const a = document.createElement('a');267 if (kid.k !== 'd') {268 const icon = document.createElement('span');269 icon.className = kid.i ? `si si-${kid.i}` : 'si';270 icon.setAttribute('aria-hidden', 'true');271 const name = document.createElement('span');272 name.className = 'name';273 name.textContent = kid.n;274 a.append(icon, name);275 a.href = `${base}${named(path)}`;276 li.append(a);277 return li;278 }279 a.textContent = kid.n;280 a.href = `${base}${path}/`;281 const details = document.createElement('details');282 details.dataset.lazy = path;283 const summary = document.createElement('summary');284 summary.append(a);285 details.append(summary, document.createElement('ul'));286 li.append(details);287 return li;288}289290function fill(details, data) {291 const list = details.querySelector(':scope > ul');292 if (!list || list.children.length) return;293 const path = details.dataset.lazy;294 const node = find(data, path);295 if (!node) return;296 for (const kid of node.c ?? []) list.append(branch(data.base ?? '/git/', kid, path ? `${path}/${kid.n}` : kid.n));297}298299function expand(event) {300 const details = event.target;301 if (!(details instanceof HTMLDetailsElement) || !details.open || details.dataset.lazy === undefined) return;302 const url = details.closest('.tree')?.dataset.source;303 if (!url) return;304 fetchTree(url).then((data) => data && fill(details, data));305}306307/* WIRE */308309const wired = new WeakSet();310311const once = (selector, fn) => {312 for (const el of document.querySelectorAll(selector)) {313 if (wired.has(el)) continue;314 wired.add(el);315 fn(el);316 }317};318319export function wire() {320 sync();321 unmark();322 theme(root().dataset.theme ?? '');323 face(root().dataset.font ?? '');324 tint(root().dataset.tint ?? '');325 screen(root().dataset.saver ?? '');326 cart();327 once('.contents', contents);328 once('.tree', reveal);329 marked();330}331332function boot() {333 root().classList.add('js');334 if ('scrollRestoration' in history) history.scrollRestoration = 'manual';335 ready(land);336 window.addEventListener('theme', halves);337 theme(read(KEY.theme));338 face(read(KEY.font));339 tint(read(KEY.tint));340 screen(read(KEY.saver));341 stow();342 matchMedia(DOCK).addEventListener('change', stow);343 document.addEventListener('click', (e) => {344 const target = e.target instanceof Element ? e.target : null;345 if (!target) return;346 const button = target.closest('[data-pane]');347 if (button) return toggle(button.dataset.pane);348 if (target.closest('[data-theme-toggle]')) return turn();349 if (target.closest('.scrim') || target.closest('.pane a[href]')) shut();350 });351 document.addEventListener('change', (e) => {352 const pick = e.target instanceof Element ? e.target : null;353 if (!pick) return;354 if (pick.matches('[data-font-pick]')) return face(pick.value);355 if (pick.matches('[data-tint-pick]')) return tint(pick.value);356 if (pick.matches('[data-saver-pick]')) return replay(pick.value);357 });358 document.addEventListener('toggle', expand, true);359 document.addEventListener('keydown', (e) => {360 if (e.key === 'Escape' && shut()) e.preventDefault();361 });362 window.addEventListener('cart', cart);363 window.addEventListener('storage', cart);364 window.addEventListener('pageshow', cart);365 window.addEventListener('pagehide', keep);366 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);367 else wire();368}369370if (typeof document !== 'undefined') boot();