283 lines (254 loc) · 11.24 KB
  1import { $, $$, asElement, prefersReducedMotion, sessionStore } from "./dom";
  2
  3// ---------------------------------------------------------------------------
  4// Page cache. A prefetch (or click) stores the pending HTML so the network
  5// round-trip is done by render time. Bounded to CACHE_MAX, oldest evicted.
  6// ---------------------------------------------------------------------------
  7const CACHE_MAX = 32;
  8const cache = new Map<string, Promise<string>>();
  9
 10const fetchPage = (url: string): Promise<string> => {
 11  let pending = cache.get(url);
 12  if (!pending) {
 13    pending = fetch(url, { headers: { "X-Router": "1" } })
 14      .then((res) => {
 15        if (!res.ok) throw new Error("bad status");
 16        return res.text();
 17      })
 18      .catch((err) => {
 19        cache.delete(url);
 20        throw err;
 21      });
 22    cache.set(url, pending);
 23    if (cache.size > CACHE_MAX) {
 24      const oldest = cache.keys().next().value;
 25      if (oldest !== undefined) cache.delete(oldest);
 26    }
 27  }
 28  return pending;
 29};
 30
 31// ---------------------------------------------------------------------------
 32// <head> sync. Only page-specific tags are swapped; the inlined <style>/<script>
 33// and charset/viewport are shared and left alone (re-running the script would
 34// re-bind everything). importNode (not cloneNode) re-homes the node into this
 35// document, avoiding Firefox's cross-document "Permission denied" error.
 36// ---------------------------------------------------------------------------
 37const HEAD_SELECTOR =
 38  'title, meta[name="description"], meta[property^="og:"], meta[name^="twitter:"], link[rel="canonical"]';
 39
 40const syncHead = (doc: Document): void => {
 41  $$(HEAD_SELECTOR, document.head).forEach((el) => el.remove());
 42  $$(HEAD_SELECTOR, doc.head).forEach((el) =>
 43    document.head.appendChild(document.importNode(el, true))
 44  );
 45};
 46
 47// ---------------------------------------------------------------------------
 48// Scrolling. Custom eased scroll (native smooth scroll offers no duration
 49// control). Per-path memory is restored only on Back/Forward; link clicks open
 50// at the top. currentPath also lets popstate tell a real navigation from an
 51// in-page hash change.
 52// ---------------------------------------------------------------------------
 53let scrollRaf = 0;
 54const stopScrollAnim = (): void => {
 55  cancelAnimationFrame(scrollRaf);
 56  scrollRaf = 0;
 57  window.scrollTo(window.scrollX, window.scrollY); // also kill native behavior:'smooth'
 58};
 59
 60const smoothScrollTo = (to: number, duration = 350): void => {
 61  stopScrollAnim();
 62  const start = window.scrollY;
 63  const distance = to - start;
 64  const t0 = performance.now();
 65  const step = (now: number) => {
 66    const p = Math.min((now - t0) / duration, 1);
 67    window.scrollTo(0, start + distance * (1 - (1 - p) ** 3)); // easeOutCubic
 68    if (p < 1) scrollRaf = requestAnimationFrame(step);
 69  };
 70  scrollRaf = requestAnimationFrame(step);
 71};
 72
 73let currentPath = location.pathname;
 74
 75// Scroll memory is per path, in sessionStorage - which only stores strings, so
 76// the position round-trips through String()/Number().
 77const saveScroll = (path: string): void => {
 78  sessionStore?.setItem(path, String(window.scrollY));
 79};
 80const savedScroll = (path: string): number =>
 81  Number(sessionStore?.getItem(path)) || 0;
 82
 83// Re-triggerable blink on the element we just jumped to. CSS :target only fires
 84// on a full page load, so anchor clicks and SPA navigations blink via this class.
 85const flashTarget = (el: HTMLElement | null): void => {
 86  if (!el) return;
 87  el.classList.remove("is-flash");
 88  void el.offsetWidth; // reflow so re-adding replays the animation
 89  el.classList.add("is-flash");
 90  el.addEventListener("animationend", () => el.classList.remove("is-flash"), { once: true });
 91};
 92
 93// Clicking a heading copies its link (you're already there — nothing to scroll to).
 94// A floating toast pops up at the cursor, outside the layout flow.
 95const copyHeadingLink = (url: string, x: number, y: number): void => {
 96  navigator.clipboard.writeText(url).then(() => {
 97    const tip = document.createElement("div");
 98    tip.className = "copied-toast";
 99    tip.textContent = "link copied";
100    tip.style.left = `${x}px`;
101    tip.style.top = `${y}px`;
102    document.body.appendChild(tip);
103    setTimeout(() => tip.remove(), 1200);
104  }).catch(() => {});
105};
106
107// ---------------------------------------------------------------------------
108// Navigation. Fetch the target, swap <main> + the header (so server-rendered
109// active-nav comes along and the mobile menu resets), sync the <head>, then
110// re-wire the page. adoptNode re-homes parsed nodes into this document before
111// insertion (Firefox cross-document guard). Wrapped in the View Transitions
112// API where available.
113// ---------------------------------------------------------------------------
114let afterSwap: () => void = () => {};
115let navSeq = 0;
116
117export const navigate = async (url: string, push = true): Promise<void> => {
118  const next = new URL(url, location.href);
119  if (push && next.pathname === location.pathname && next.search === location.search && next.hash === location.hash) {
120    stopScrollAnim();
121    window.scrollTo(0, 0);
122    const menu = $<HTMLDetailsElement>(".menu-mobile");
123    if (menu) menu.open = false;
124    return;
125  }
126
127  stopScrollAnim();
128  saveScroll(currentPath); // remember the page we're leaving
129  const seq = ++navSeq;
130
131  let html: string;
132  try {
133    html = await fetchPage(url);
134  } catch (_) {
135    location.href = url;
136    return;
137  }
138  if (seq !== navSeq) return;
139
140  const doc = new DOMParser().parseFromString(html, "text/html");
141  const nextMain = doc.querySelector("main");
142  const curMain = $("main");
143  if (!nextMain || !curMain) {
144    location.href = url;
145    return;
146  }
147
148  if (push) history.pushState(null, "", url);
149
150  const render = () => {
151    const nextHeader = doc.querySelector(".site-header-outer");
152    const curHeader = $(".site-header-outer");
153    if (nextHeader && curHeader) curHeader.replaceWith(document.adoptNode(nextHeader));
154    curMain.replaceWith(document.adoptNode(nextMain));
155    syncHead(doc);
156    afterSwap();
157    currentPath = location.pathname;
158    // Link clicks open at the top (or at the linked #anchor); Back/Forward
159    // restore the saved position. Re-apply next frame so late layout can't clamp.
160    const anchor = push && location.hash
161      ? document.getElementById(decodeURIComponent(location.hash.slice(1)))
162      : null;
163    let y = push ? 0 : savedScroll(currentPath);
164    if (anchor) {
165      const margin = parseFloat(getComputedStyle(anchor).scrollMarginTop) || 0;
166      y = anchor.getBoundingClientRect().top + window.scrollY - margin;
167      flashTarget(anchor);
168    }
169    window.scrollTo(0, y);
170    if (y) requestAnimationFrame(() => window.scrollTo(0, y));
171  };
172
173  // No startViewTransition: its whole-page crossfade reads as a blink on
174  // every swap — the instant replace is the point of the PJAX router.
175  render();
176};
177
178// ---------------------------------------------------------------------------
179// Event wiring.
180// ---------------------------------------------------------------------------
181const isModifiedClick = (e: MouseEvent): boolean =>
182  e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
183
184// Same-origin, in-page, non-download link we're willing to handle.
185const isNavigable = (a: HTMLAnchorElement | null | undefined): a is HTMLAnchorElement =>
186  !!a &&
187  a.origin === location.origin &&
188  !a.hasAttribute("download") &&
189  (!a.target || a.target === "_self");
190
191// const eligibleForPrefetch = (a: HTMLAnchorElement | null | undefined): a is HTMLAnchorElement => {
192//   if (!isNavigable(a) || a.pathname === location.pathname) return false;
193//   const href = a.getAttribute("href");
194//   return !!href && !href.startsWith("#");
195// };
196
197// Scroll to an in-page anchor without touching history or the URL. Returns
198// whether a target was found (and thus the click should be intercepted).
199const scrollToAnchor = (hash: string): boolean => {
200  const target = document.getElementById(decodeURIComponent(hash.slice(1)));
201  if (!target) return false;
202  const margin = parseFloat(getComputedStyle(target).scrollMarginTop) || 0;
203  const to = target.getBoundingClientRect().top + window.scrollY - margin;
204  if (prefersReducedMotion()) window.scrollTo(0, to);
205  else smoothScrollTo(to);
206  target.focus({ preventScroll: true }); // move focus (skip link); no-op on non-focusable targets
207  flashTarget(target);
208  return true;
209};
210
211/** Bind the document/window-level listeners. Run once for the session. */
212export const startRouter = (onAfterSwap: () => void): void => {
213  afterSwap = onAfterSwap;
214  history.scrollRestoration = "manual";
215
216  // The page we booted on is already in the DOM — seed the cache with it so
217  // navigating back here doesn't re-fetch what we already have.
218  cache.set(location.href, Promise.resolve(document.documentElement.outerHTML));
219
220  // Intent prefetch: warm the cache after the cursor/focus rests on a link for
221  // ~150ms; cancel if it leaves first. Sweeping/tabbing through prefetches none.
222  // let intentTimer: number | undefined;
223  // const scheduleWarm = (e: Event) => {
224  //   const a = asElement(e.target)?.closest<HTMLAnchorElement>("a");
225  //   if (!eligibleForPrefetch(a)) return;
226  //   clearTimeout(intentTimer);
227  //   intentTimer = setTimeout(() => fetchPage(a.href).catch(() => {}), 150);
228  // };
229  // const cancelWarm = () => clearTimeout(intentTimer);
230  // document.addEventListener("mouseover", scheduleWarm, { passive: true });
231  // document.addEventListener("focusin", scheduleWarm, { passive: true });
232  // document.addEventListener("mouseout", cancelWarm, { passive: true });
233  // document.addEventListener("focusout", cancelWarm, { passive: true });
234
235  // Intercept same-origin link clicks for a body-only swap. In-page anchors
236  // just scroll (no history/URL write).
237  document.addEventListener("click", (e) => {
238    if (isModifiedClick(e)) return;
239    const a = asElement(e.target)?.closest<HTMLAnchorElement>("a");
240    if (!isNavigable(a)) return;
241    const href = a.getAttribute("href");
242    if (!href) return;
243
244    if (a.hash && a.pathname === location.pathname) {
245      const heading = a.closest("h1, h2, h3, h4, h5, h6");
246      if (heading && navigator.clipboard) {
247        e.preventDefault();
248        const r = heading.getBoundingClientRect(); // keyboard (pageX 0) → near the heading
249        copyHeadingLink(
250          a.href,
251          e.pageX || r.left + window.scrollX + 16,
252          e.pageY || r.top + window.scrollY + r.height / 2,
253        );
254        return;
255      }
256      if (scrollToAnchor(a.hash)) e.preventDefault();
257      return;
258    }
259    if (href.startsWith("#")) return; // unresolved hash — leave it to the browser
260    e.preventDefault();
261    navigate(a.href);
262  });
263
264  window.addEventListener("popstate", () => {
265    if (location.pathname === currentPath) return; // in-page hash change
266    navigate(location.href, false);
267  });
268
269  // Leaving to an external site skips navigate() entirely, so its scroll-save
270  // never runs. pagehide covers that (and doesn't disable bfcache like
271  // beforeunload would); pageshow restores on the way back, whether the page
272  // reloaded fresh or was revived from bfcache.
273  window.addEventListener("pagehide", () => {
274    saveScroll(currentPath);
275  });
276  window.addEventListener("pageshow", () => {
277    const y = savedScroll(currentPath);
278    if (y) {
279      window.scrollTo(0, y);
280      requestAnimationFrame(() => window.scrollTo(0, y));
281    }
282  });
283};