kepr.js

5.9 KB · raw

1// Kepr's browser islands (§18). Every page is complete without this file; it only adds the2// styled timeline tooltip, copy buttons, line-range highlighting, the Atlas panel and filter,3// and the profile backdrop's glow.45function timelineTips() {6	const dots = document.querySelectorAll(".timeline .dot");7	if (dots.length === 0) return;8	const tip = document.createElement("div");9	tip.className = "timeline-tip";10	tip.hidden = true;11	document.body.append(tip);12	for (const dot of dots) {13		// The SVG <title> is the no-JavaScript tooltip; lift it out so the two never show together.14		const title = dot.querySelector("title");15		const [id, message, meta] = (title?.textContent ?? "").split("\n");16		title?.remove();17		dot.setAttribute("aria-label", `${id} ${message}`);18		dot.addEventListener("pointerenter", (event) => {19			tip.replaceChildren(line("tip-id", id), line("tip-msg", message), line("tip-meta", meta));20			tip.hidden = false;21			const left = event.clientX + 14;22			tip.style.left = `${left + 340 > window.innerWidth ? Math.max(8, event.clientX - 354) : left}px`;23			tip.style.top = `${event.clientY - 10}px`;24		});25		dot.addEventListener("pointerleave", () => {26			tip.hidden = true;27		});28	}29}3031function line(className, text) {32	const div = document.createElement("div");33	div.className = className;34	div.textContent = text ?? "";35	return div;36}3738function copyButtons() {39	for (const button of document.querySelectorAll(".copy-btn[data-copy]")) {40		button.hidden = false;41		button.addEventListener("click", async () => {42			await navigator.clipboard.writeText(button.dataset.copy);43			button.textContent = "copied";44			button.classList.add("copied");45			setTimeout(() => {46				button.textContent = "copy";47				button.classList.remove("copied");48			}, 1400);49		});50	}51}5253// `#L42` works through CSS :target alone; a range `#L42-L50` needs marking line by line.54function lineRanges() {55	const code = document.querySelector(".code");56	if (!code) return;57	let anchor = null;58	const mark = () => {59		for (const marked of code.querySelectorAll(".line.hl")) marked.classList.remove("hl");60		const match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);61		if (!match) return;62		const from = Number(match[1]);63		const to = Number(match[2] ?? match[1]);64		anchor = from;65		for (let n = Math.min(from, to); n <= Math.max(from, to); n++) {66			document.getElementById(`L${n}`)?.classList.add("hl");67		}68		document.getElementById(`L${Math.min(from, to)}`)?.scrollIntoView({ block: "center" });69	};70	code.addEventListener("click", (event) => {71		const link = event.target.closest(".ln");72		if (!link || !event.shiftKey || anchor === null) return;73		event.preventDefault();74		const n = Number(link.textContent);75		history.replaceState(null, "", `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`);76		mark();77	});78	window.addEventListener("hashchange", mark);79	mark();80}8182// The Atlas (§21.5): each row's detail panel waits in the <template> after it; the filter narrows83// by filename; which folders are open is remembered per repo in this browser.84function atlas() {85	const root = document.querySelector(".atlas");86	const filter = document.querySelector(".atlas-filter");87	if (!root || !filter) return;88	const panel = root.querySelector(".atlas-panel");89	const folders = [...root.querySelectorAll(".atlas-folder")];90	const files = [...root.querySelectorAll(".atlas-file")];91	const key = `kepr:atlas:${root.dataset.repo}`;92	const opened = new Set(remembered(key));93	for (const folder of folders) {94		folder.open = opened.has(folder.dataset.path);95		folder.addEventListener("toggle", () => {96			if (filter.value.trim()) return;97			if (folder.open) opened.add(folder.dataset.path);98			else opened.delete(folder.dataset.path);99			remember(key, [...opened]);100		});101	}102	let selected = null;103	const show = (row) => {104		selected?.classList.remove("selected");105		selected = row;106		row.classList.add("selected");107		panel.replaceChildren(row.nextElementSibling.content.cloneNode(true));108	};109	for (const row of root.querySelectorAll(".atlas-row")) {110		row.addEventListener("pointerenter", (event) => {111			if (event.pointerType === "mouse") show(row);112		});113		row.addEventListener("focus", () => show(row));114	}115	filter.addEventListener("input", () => {116		const query = filter.value.trim().toLowerCase();117		for (const file of files) file.hidden = query !== "" && !file.dataset.name.includes(query);118		for (const folder of folders) {119			folder.hidden = query !== "" && !folder.querySelector(".atlas-file:not([hidden])");120			folder.open = query !== "" || opened.has(folder.dataset.path);121		}122	});123	filter.hidden = false;124	panel.hidden = false;125}126127// Storage can be missing, refused (private windows, blocked windows) or hold anything; only a128// list of folder paths is trusted, and otherwise the tree starts closed.129function remembered(key) {130	try {131		const value = JSON.parse(localStorage.getItem(key) ?? "[]");132		return Array.isArray(value) && value.every((path) => typeof path === "string") ? value : [];133	} catch {134		return [];135	}136}137138function remember(key, value) {139	try {140		localStorage.setItem(key, JSON.stringify(value));141	} catch {}142}143144// The profile's hex backdrop already breathes in CSS; this lights a few columns at a time, as145// Kepr 2's canvas did. Nothing moves under reduced motion, including when it is switched on later.146function hexGlow() {147	const columns = document.querySelectorAll(".hex-col");148	if (columns.length === 0) return;149	const reduce = matchMedia("(prefers-reduced-motion: reduce)");150	let timer = null;151	const update = () => {152		clearInterval(timer);153		for (const column of columns) column.classList.remove("glow");154		if (reduce.matches) return;155		timer = setInterval(() => {156			const column = columns[Math.floor(Math.random() * columns.length)];157			if (document.querySelectorAll(".hex-col.glow").length >= 3) return;158			column.classList.add("glow");159			setTimeout(() => column.classList.remove("glow"), 3200);160		}, 1500);161	};162	reduce.addEventListener("change", update);163	update();164}165166timelineTips();167copyButtons();168lineRanges();169atlas();170hexGlow();