kepr.js

5.0 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 and the Atlas panel and filter.34function timelineTips() {5	const dots = document.querySelectorAll(".timeline .dot");6	if (dots.length === 0) return;7	const tip = document.createElement("div");8	tip.className = "timeline-tip";9	tip.hidden = true;10	document.body.append(tip);11	for (const dot of dots) {12		// The SVG <title> is the no-JavaScript tooltip; lift it out so the two never show together.13		const title = dot.querySelector("title");14		const [id, message, meta] = (title?.textContent ?? "").split("\n");15		title?.remove();16		dot.setAttribute("aria-label", `${id} ${message}`);17		dot.addEventListener("pointerenter", (event) => {18			tip.replaceChildren(line("tip-id", id), line("tip-msg", message), line("tip-meta", meta));19			tip.hidden = false;20			const left = event.clientX + 14;21			tip.style.left = `${left + 340 > window.innerWidth ? Math.max(8, event.clientX - 354) : left}px`;22			tip.style.top = `${event.clientY - 10}px`;23		});24		dot.addEventListener("pointerleave", () => {25			tip.hidden = true;26		});27	}28}2930function line(className, text) {31	const div = document.createElement("div");32	div.className = className;33	div.textContent = text ?? "";34	return div;35}3637function copyButtons() {38	for (const button of document.querySelectorAll(".copy-btn[data-copy]")) {39		button.hidden = false;40		button.addEventListener("click", async () => {41			await navigator.clipboard.writeText(button.dataset.copy);42			button.textContent = "copied";43			button.classList.add("copied");44			setTimeout(() => {45				button.textContent = "copy";46				button.classList.remove("copied");47			}, 1400);48		});49	}50}5152// `#L42` works through CSS :target alone; a range `#L42-L50` needs marking line by line.53function lineRanges() {54	const code = document.querySelector(".code");55	if (!code) return;56	let anchor = null;57	const mark = () => {58		for (const marked of code.querySelectorAll(".line.hl")) marked.classList.remove("hl");59		const match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);60		if (!match) return;61		const from = Number(match[1]);62		const to = Number(match[2] ?? match[1]);63		anchor = from;64		for (let n = Math.min(from, to); n <= Math.max(from, to); n++) {65			document.getElementById(`L${n}`)?.classList.add("hl");66		}67		document.getElementById(`L${Math.min(from, to)}`)?.scrollIntoView({ block: "center" });68	};69	code.addEventListener("click", (event) => {70		const link = event.target.closest(".ln");71		if (!link || !event.shiftKey || anchor === null) return;72		event.preventDefault();73		const n = Number(link.textContent);74		history.replaceState(null, "", `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`);75		mark();76	});77	window.addEventListener("hashchange", mark);78	mark();79}8081// The Atlas (§21.5): each row's detail panel waits in the <template> after it; the filter narrows82// by filename; which folders are open is remembered per repo in this browser.83function atlas() {84	const root = document.querySelector(".atlas");85	const filter = document.querySelector(".atlas-filter");86	if (!root || !filter) return;87	const panel = root.querySelector(".atlas-panel");88	const folders = [...root.querySelectorAll(".atlas-folder")];89	const files = [...root.querySelectorAll(".atlas-file")];90	const key = `kepr:atlas:${root.dataset.repo}`;91	const opened = new Set(remembered(key));92	for (const folder of folders) {93		folder.open = opened.has(folder.dataset.path);94		folder.addEventListener("toggle", () => {95			if (filter.value.trim()) return;96			if (folder.open) opened.add(folder.dataset.path);97			else opened.delete(folder.dataset.path);98			remember(key, [...opened]);99		});100	}101	let selected = null;102	const show = (row) => {103		selected?.classList.remove("selected");104		selected = row;105		row.classList.add("selected");106		panel.replaceChildren(row.nextElementSibling.content.cloneNode(true));107	};108	for (const row of root.querySelectorAll(".atlas-row")) {109		row.addEventListener("pointerenter", (event) => {110			if (event.pointerType === "mouse") show(row);111		});112		row.addEventListener("focus", () => show(row));113	}114	filter.addEventListener("input", () => {115		const query = filter.value.trim().toLowerCase();116		for (const file of files) file.hidden = query !== "" && !file.dataset.name.includes(query);117		for (const folder of folders) {118			folder.hidden = query !== "" && !folder.querySelector(".atlas-file:not([hidden])");119			folder.open = query !== "" || opened.has(folder.dataset.path);120		}121	});122	filter.hidden = false;123	panel.hidden = false;124}125126// Storage can be missing, refused (private windows, blocked site data) or hold anything; only a list127// of folder paths is trusted, and otherwise the tree starts closed.128function remembered(key) {129	try {130		const value = JSON.parse(localStorage.getItem(key) ?? "[]");131		return Array.isArray(value) && value.every((path) => typeof path === "string") ? value : [];132	} catch {133		return [];134	}135}136137function remember(key, value) {138	try {139		localStorage.setItem(key, JSON.stringify(value));140	} catch {}141}142143timelineTips();144copyButtons();145lineRanges();146atlas();