stacked-pages/src/components/scripts/stacked.inline.ts
saberzero1 ccbe026e34
fix: use fetchData global for path-prefixed deployments
- Update types/globals.d.ts with centralized type declarations
- Use fetchData global instead of hardcoded /static/contentIndex.json
- Revert @quartz-community/types from file:../types to github:quartz-community/types
- Add .prettierignore, update .eslintrc.json with triple-slash-reference override
- Remove duplicate scripts.d.ts/styles.d.ts
2026-03-16 18:54:25 +01:00

302 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Stacked Pages — Binder-style tab navigation for Quartz v5
//
// Architecture:
// - State lives in sessionStorage (no URL manipulation — hash anchors & popovers work)
// - Quartz's SPA router handles ALL navigation; we never intercept clicks
// - We listen to Quartz "nav" events to learn about page changes and update tabs
// - Tab clicks use window.spaNavigate() — the Quartz-approved navigation API
// - The binder is a pure UI overlay rendered beside the page content
// ── Types ─────────────────────────────────────────────────────────────
interface Tab {
slug: string;
title: string;
}
interface BinderState {
tabs: Tab[];
activeIndex: number;
}
// ── Constants ─────────────────────────────────────────────────────────
const STORAGE_KEY = "stacked-pages-state";
// ── Helpers ───────────────────────────────────────────────────────────
function getCurrentSlug(): string {
let slug = window.location.pathname;
if (slug.startsWith("/")) slug = slug.slice(1);
if (slug.endsWith("/")) slug = slug.slice(0, -1);
return slug || "index";
}
function getPageTitle(): string {
const h1 = document.querySelector("h1");
return h1?.textContent?.trim() || document.title || getCurrentSlug();
}
// ── SessionStorage state management ───────────────────────────────────
export function loadState(): BinderState {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed.tabs) && typeof parsed.activeIndex === "number") {
return parsed;
}
}
} catch {
// Ignore corrupted state
}
return { tabs: [], activeIndex: -1 };
}
export function saveState(state: BinderState): void {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Ignore storage write failures (e.g. quota exceeded, private browsing)
}
}
function readConfig(container: HTMLElement) {
return {
maxTabs: parseInt(container.dataset.maxTabs || "8", 10),
mobileBreakpoint: parseInt(container.dataset.mobileBreakpoint || "800", 10),
showSpines: container.dataset.showSpines !== "false",
animate: container.dataset.animate !== "false",
};
}
// ── Binder rendering ──────────────────────────────────────────────────
function renderBinder(container: HTMLElement, state: BinderState) {
const config = readConfig(container);
// Hide on mobile
if (window.innerWidth < config.mobileBreakpoint) {
container.style.display = "none";
document.body.classList.remove("has-binder-left", "has-binder-right");
return;
}
container.style.display = "";
// Don't render binder if only one tab (no point showing tabs for a single page)
if (state.tabs.length <= 1) {
container.innerHTML = "";
container.classList.remove("binder-active");
document.body.classList.remove("has-binder-left", "has-binder-right");
return;
}
container.classList.add("binder-active");
// Split tabs into left (before active) and right (after active)
const leftTabs = state.tabs.slice(0, state.activeIndex + 1);
const rightTabs = state.tabs.slice(state.activeIndex + 1);
container.innerHTML = "";
// Left tab strip
if (leftTabs.length > 0) {
const leftStrip = document.createElement("div");
leftStrip.className = "binder-strip binder-strip-left";
for (let i = 0; i < leftTabs.length; i++) {
const tab = leftTabs[i]!;
leftStrip.appendChild(createTabElement(tab, i, "left", state, config));
}
container.appendChild(leftStrip);
}
// Right tab strip
if (rightTabs.length > 0) {
const rightStrip = document.createElement("div");
rightStrip.className = "binder-strip binder-strip-right";
for (let i = 0; i < rightTabs.length; i++) {
const realIndex = state.activeIndex + 1 + i;
const tab = rightTabs[i]!;
rightStrip.appendChild(createTabElement(tab, realIndex, "right", state, config));
}
container.appendChild(rightStrip);
}
// Toggle body classes so CSS can push content aside
document.body.classList.toggle("has-binder-left", leftTabs.length > 0);
document.body.classList.toggle("has-binder-right", rightTabs.length > 0);
}
function createTabElement(
tab: Tab,
index: number,
side: "left" | "right",
state: BinderState,
config: { showSpines: boolean },
): HTMLElement {
const el = document.createElement("div");
el.className = `binder-tab binder-tab-${side}`;
el.dataset.index = String(index);
if (index === state.activeIndex) {
el.classList.add("binder-tab-active");
}
// Spine accent bar
if (config.showSpines) {
const spine = document.createElement("div");
spine.className = "binder-spine";
el.appendChild(spine);
}
// Label
const label = document.createElement("span");
label.className = "binder-label";
label.textContent = tab.title;
el.appendChild(label);
// Close button (only when 2+ tabs)
if (state.tabs.length >= 2) {
const closeBtn = document.createElement("button");
closeBtn.className = "binder-close";
closeBtn.textContent = "×";
closeBtn.setAttribute("aria-label", `Close ${tab.title}`);
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
closeTab(index);
});
el.appendChild(closeBtn);
}
// Click to navigate
el.addEventListener("click", () => {
navigateToTab(index);
});
return el;
}
// ── Tab operations ────────────────────────────────────────────────────
function navigateToTab(index: number) {
const state = loadState();
if (index < 0 || index >= state.tabs.length) return;
const tab = state.tabs[index];
if (!tab) return;
// Update active index before navigation so the nav handler knows this is a tab switch
state.activeIndex = index;
saveState(state);
// Use Quartz's SPA navigation
const url = new URL(`/${tab.slug}`, window.location.origin);
if (window.spaNavigate) {
window.spaNavigate(url, false);
} else {
window.location.href = url.toString();
}
}
function closeTab(index: number) {
const state = loadState();
if (state.tabs.length < 2) return;
const wasActive = index === state.activeIndex;
state.tabs.splice(index, 1);
if (wasActive) {
// Navigate to the nearest remaining tab
state.activeIndex = Math.min(index, state.tabs.length - 1);
saveState(state);
navigateToTab(state.activeIndex);
} else {
// Adjust active index if we removed a tab before it
if (index < state.activeIndex) {
state.activeIndex--;
}
saveState(state);
renderBinderUI();
}
}
// ── Main nav handler ──────────────────────────────────────────────────
let lastProcessedSlug: string | null = null;
function handleNavigation() {
const container = document.getElementById("stacked-pages-container");
if (!container) return;
const slug = getCurrentSlug();
const title = getPageTitle();
// Avoid processing the same slug twice (e.g. render event after nav)
if (slug === lastProcessedSlug) {
renderBinder(container, loadState());
return;
}
lastProcessedSlug = slug;
const state = loadState();
const config = readConfig(container);
// Check if this slug already exists in tabs
const existingIndex = state.tabs.findIndex((t) => t.slug === slug);
if (existingIndex >= 0) {
// Tab already exists — just switch to it, update title in case it changed
state.tabs[existingIndex]!.title = title;
state.activeIndex = existingIndex;
} else {
// New page — insert as a new tab after the current active tab
const newTab: Tab = { slug, title };
const insertAt = state.activeIndex + 1;
state.tabs.splice(insertAt, 0, newTab);
state.activeIndex = insertAt;
// Evict oldest tabs if over limit (evict from the opposite end of active)
while (state.tabs.length > config.maxTabs) {
if (state.activeIndex > 0) {
// Evict from the start
state.tabs.shift();
state.activeIndex--;
} else {
// Evict from the end
state.tabs.pop();
}
}
}
saveState(state);
renderBinder(container, state);
}
function renderBinderUI() {
const container = document.getElementById("stacked-pages-container");
if (!container) return;
renderBinder(container, loadState());
}
// ── Initialization ────────────────────────────────────────────────────
function init() {
handleNavigation();
// Re-render on resize (mobile breakpoint)
const resizeHandler = () => renderBinderUI();
window.addEventListener("resize", resizeHandler);
if (window.addCleanup) {
window.addCleanup(() => window.removeEventListener("resize", resizeHandler));
}
}
// Listen to Quartz navigation events
if (typeof document !== "undefined") {
document.addEventListener("nav", () => {
init();
});
// Re-render on in-place content changes (e.g. decryption)
document.addEventListener("render", () => {
renderBinderUI();
});
}