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();