kepr.js

22.6 KB · raw

1// Kepr's browser islands (§18). Every page is complete without this file; it only adds the2// ticker's pace, the drifting timeline, copy buttons, the inline panels, history's scrolling,3// line-range highlighting, the Atlas panel, picker, filter and file viewer, and the profile's4// glow.56// Kepr 2's save kinds (canvas.js), first match wins: each dot's colour and strength.7const KINDS = [8	[/\b(release|version|bump|tag)\b/i, "accent", 1],9	[/\b(fix|bug|patch|issue|crash|error|regress|revert)\b/i, "accent-dim", 0.8],10	[/\b(add|new|implement|feat|introduce|wire|support)\b/i, "accent", 1],11	[12		/\b(refactor|restructure|reorganize|extract|move|rename|split|cleanup|clean up)\b/i,13		"text",14		0.85,15	],16	[/\b(perf|optim|speed|fast|slow|cache|memory|alloc)\b/i, "accent", 0.9],17	[/\b(test|spec|coverage|assert)\b/i, "text-secondary", 0.74],18	[/\b(doc|readme|comment|example|guide)\b/i, "text-secondary", 0.7],19	[/\b(polish|style|format|lint|whitespace|typo|wording|copy)\b/i, "text-secondary", 0.66],20	[/\b(chore|deps|update|upgrade|build|ci|config)\b/i, "text-secondary", 0.62],21];22const DAY = 86400;2324// Kepr 2's timeline (canvas.js keprThread): the saves on a strip three views wide that drifts25// slowly and loops, dots pushed apart so none overlap. Hovering pauses it and shows the save,26// dragging scrubs, a click opens the save. Under reduced motion it moves only when dragged.27function timeline() {28	const svg = document.querySelector("svg.timeline");29	if (!svg) return;30	const css = getComputedStyle(document.documentElement);31	const saves = [...svg.querySelectorAll(".dot")]32		.map((dot) => {33			const [id, message, meta] = (dot.querySelector("title")?.textContent ?? "").split("\n");34			const [, color, alpha] = KINDS.find(([kind]) => kind.test(message)) ?? [35				null,36				"text-secondary",37				0.8,38			];39			const fill = css.getPropertyValue(`--${color}`).trim();40			return { href: dot.getAttribute("href"), id, message, meta, fill, alpha, t: +dot.dataset.t };41		})42		.sort((a, b) => a.t - b.t);43	if (saves.length === 0) return;44	const canvas = document.createElement("canvas");45	canvas.className = "timeline-canvas";46	canvas.setAttribute("role", "img");47	canvas.setAttribute("aria-label", svg.getAttribute("aria-label"));48	svg.replaceWith(canvas);49	const ctx = canvas.getContext("2d");50	const tip = document.createElement("div");51	tip.className = "timeline-tip";52	tip.hidden = true;53	document.body.append(tip);54	const reduce = matchMedia("(prefers-reduced-motion: reduce)");55	const [first, last] = [saves[0].t, saves[saves.length - 1].t];56	const end = Math.max(Date.now() / 1000, last) + DAY;57	const start = Math.min(first, Date.now() / 1000) - Math.max(0, 7 * DAY - (end - first));58	let [width, height, strip, radius, offset] = [0, 0, 0, 0, 0];59	const xOf = (t) => (strip * (t - start)) / (end - start);60	const resize = () => {61		width = canvas.parentElement.getBoundingClientRect().width;62		// Kepr 2's band was tall to hold two lanes; with one line, only room for the dots and label.63		height = 64;64		canvas.width = Math.round(width * devicePixelRatio);65		canvas.height = Math.round(height * devicePixelRatio);66		canvas.style.width = `${width}px`;67		canvas.style.height = `${height}px`;68		ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);69		strip = Math.max(width * 3, saves.length * 48);70		radius = 4;71		let previous = Number.NEGATIVE_INFINITY;72		for (const save of saves) {73			save.x = Math.max(xOf(save.t), previous + radius + 8);74			previous = save.x;75		}76		offset = Math.max(0, previous - width * 0.82);77	};78	// Every visible copy of each x on the looping strip.79	const onScreen = (x, margin) => {80		const shift = ((offset % strip) + strip) % strip;81		return [-1, 0, 1]82			.map((k) => x - shift + k * strip)83			.filter((sx) => sx > -margin && sx < width + margin);84	};85	const hit = (event) => {86		const box = canvas.getBoundingClientRect();87		const [mx, my] = [event.clientX - box.left, event.clientY - box.top];88		if (Math.abs(my - height / 2) > radius + 7) return null;89		return (90			saves.find((save) => onScreen(save.x, 0).some((x) => Math.abs(mx - x) < radius + 7)) ?? null91		);92	};93	let [hovered, drag, dragFrom, downX, lastX, velocity, resumeAt, lastFrame] = [94		null,95		null,96		0,97		0,98		0,99		0,100		0,101		0,102	];103	const hover = (save, event) => {104		if (save !== hovered) {105			hovered = save;106			tip.hidden = save === null;107			if (save)108				tip.replaceChildren(109					line("tip-id", save.id),110					line("tip-msg", save.message),111					line("tip-meta", save.meta),112				);113		}114		if (save) {115			const left = event.clientX + 14;116			tip.style.left = `${left + 340 > innerWidth ? Math.max(8, event.clientX - 354) : left}px`;117			tip.style.top = `${event.clientY - 10}px`;118		}119		canvas.style.cursor = save ? "pointer" : drag === null ? "grab" : "grabbing";120	};121	const draw = (time) => {122		const dt = lastFrame ? Math.min((time - lastFrame) / 1000, 0.1) : 0;123		lastFrame = time;124		if (drag === null && hovered === null) {125			if (Math.abs(velocity) > 0.5) {126				offset -= velocity;127				velocity *= 0.92;128			} else if (!reduce.matches && time >= resumeAt) offset += 20 * dt;129		}130		const y = height / 2;131		ctx.clearRect(0, 0, width, height);132		ctx.lineWidth = 1;133		ctx.strokeStyle = css.getPropertyValue("--border");134		const years = [new Date(start * 1000).getFullYear(), new Date(end * 1000).getFullYear()];135		for (let year = years[0]; year <= years[1]; year++) {136			for (const x of onScreen(xOf(new Date(year, 0, 1) / 1000), 0)) {137				ctx.beginPath();138				ctx.moveTo(x, height * 0.1);139				ctx.lineTo(x, height * 0.9);140				ctx.stroke();141			}142		}143		ctx.globalAlpha = 0.7;144		ctx.strokeStyle = css.getPropertyValue("--text-muted");145		ctx.setLineDash([4, 4]);146		ctx.beginPath();147		ctx.moveTo(0, y);148		ctx.lineTo(width, y);149		ctx.stroke();150		ctx.setLineDash([]);151		ctx.font = '10px "Martian Mono", monospace';152		ctx.textAlign = "center";153		for (const save of saves) {154			for (const x of onScreen(save.x, 10)) {155				ctx.globalAlpha = hovered === null ? save.alpha : hovered === save ? 1 : save.alpha * 0.6;156				ctx.fillStyle = save.fill;157				ctx.beginPath();158				ctx.arc(x, y, radius, 0, Math.PI * 2);159				ctx.fill();160				if (hovered === save) {161					ctx.fillStyle = css.getPropertyValue("--accent");162					ctx.fillText(save.id.slice(0, 6), x, y - radius - 6);163				}164			}165		}166		ctx.globalAlpha = 1;167		requestAnimationFrame(draw);168	};169	canvas.addEventListener("pointerdown", (event) => {170		[drag, dragFrom, downX, lastX, velocity] = [171			event.clientX,172			offset,173			event.clientX,174			event.clientX,175			0,176		];177		canvas.setPointerCapture(event.pointerId);178	});179	canvas.addEventListener("pointermove", (event) => {180		if (drag !== null) {181			offset = dragFrom - (event.clientX - drag);182			velocity = velocity * 0.6 + (event.clientX - lastX) * 0.4;183			lastX = event.clientX;184		}185		if (event.pointerType === "mouse") hover(hit(event), event);186	});187	const release = () => {188		drag = null;189		resumeAt = performance.now() + 900;190	};191	canvas.addEventListener("pointerup", (event) => {192		release();193		const save = Math.abs(event.clientX - downX) > 4 ? null : hit(event);194		if (save) location.href = save.href;195	});196	canvas.addEventListener("pointercancel", release);197	canvas.addEventListener("pointerleave", (event) => {198		if (drag === null) hover(null, event);199	});200	addEventListener("resize", resize);201	resize();202	requestAnimationFrame(draw);203}204205function line(className, text) {206	const div = document.createElement("div");207	div.className = className;208	div.textContent = text ?? "";209	return div;210}211212function copyButtons(root) {213	for (const button of root.querySelectorAll("button[data-copy]")) {214		const label = button.textContent;215		button.hidden = false;216		button.addEventListener("click", async () => {217			await navigator.clipboard.writeText(button.dataset.copy);218			button.textContent = "copied";219			button.classList.add("copied");220			setTimeout(() => {221				button.textContent = label;222				button.classList.remove("copied");223			}, 1400);224		});225	}226}227228// A Kepr page's <main>, its links made absolute so they point where they did on their own page229// (after any redirect). An error page's <main> comes back too, so a refused form shows why; null if230// nothing came back, and the caller then goes to the page itself.231async function fetchMain(url, init) {232	try {233		const response = await fetch(url, init);234		const page = new DOMParser().parseFromString(await response.text(), "text/html");235		const main = page.querySelector("main");236		for (const link of main?.querySelectorAll("a[href]:not([href^='#'])") ?? []) {237			link.href = new URL(link.getAttribute("href"), response.url || url).href;238		}239		return main;240	} catch {241		return null;242	}243}244245// A click the browser should handle itself: a new tab or window, or a download.246function modified(event) {247	return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;248}249250// §21.9's panels open inline under the tabs, as Kepr 2's did: the panel page without its heading.251// The panel's own pages (an issue, the report form) and its forms stay inside it; a link elsewhere,252// or to a file (a download), navigates. The open tab takes the panel back to its first page, and253// closes it from there.254function panels() {255	const host = document.querySelector(".repo-panel");256	if (!host) return;257	let open = null;258	let shown = "";259	const load = async (url, init) => {260		shown = url;261		const main = await fetchMain(url, init);262		if (shown !== url) return;263		if (!main) {264			location.href = url;265			return;266		}267		main.querySelector(".breadcrumb")?.remove();268		main.querySelector(".file-page-title")?.remove();269		host.replaceChildren(...main.childNodes);270		copyButtons(host);271		host.hidden = false;272	};273	const inside = (url) => {274		const base = new URL(open.href).pathname;275		const { pathname } = new URL(url);276		return (pathname === base || pathname.startsWith(`${base}/`)) && !/\.[^/]*$/.test(pathname);277	};278	for (const tab of document.querySelectorAll(".repo-tab[data-panel]")) {279		tab.addEventListener("click", (event) => {280			if (modified(event)) return;281			event.preventDefault();282			if (open === tab && shown !== tab.href) {283				load(tab.href);284				return;285			}286			open?.classList.remove("active");287			if (open === tab) {288				open = null;289				shown = "";290				host.hidden = true;291				return;292			}293			open = tab;294			tab.classList.add("active");295			load(tab.href);296		});297	}298	host.addEventListener("click", (event) => {299		const link = event.target.closest("a[href]");300		if (!link || !open || modified(event) || link.hasAttribute("download")) return;301		if (!inside(link.href)) return;302		event.preventDefault();303		load(link.href);304	});305	host.addEventListener("submit", (event) => {306		const form = event.target;307		if (!open || !inside(form.action)) return;308		event.preventDefault();309		const method = form.method.toUpperCase();310		const data = new FormData(form);311		if (method === "GET") load(`${form.action}?${new URLSearchParams(data)}`);312		else load(form.action, { method, body: data });313	});314}315316// History and the Atlas's save picker hold one page of saves; scrolling to the end of either317// brings in the next page's rows. The pager is for pages without JavaScript.318function saveLists() {319	for (const list of document.querySelectorAll(".save-list")) {320		const observer = new IntersectionObserver(321			async ([entry]) => {322				const older = list.querySelector(".pager-older");323				if (!entry?.isIntersecting || !older) return;324				observer.disconnect();325				const main = await fetchMain(older.href);326				const next = main?.querySelector(`.save-list[data-list="${list.dataset.list}"]`);327				if (!next) return;328				next.querySelector(".pager a:not(.pager-older)")?.remove();329				list.querySelector(".pager").replaceWith(...next.childNodes);330				watch();331			},332			{ root: list },333		);334		const watch = () => {335			const older = list.querySelector(".pager-older");336			if (older) observer.observe(older);337		};338		watch();339	}340}341342// `#L42` works through CSS :target alone; a range `#L42-L50` needs marking line by line.343function lineRanges() {344	const code = document.querySelector(".code");345	if (!code) return;346	let anchor = null;347	const mark = () => {348		for (const marked of code.querySelectorAll(".line.hl")) marked.classList.remove("hl");349		const match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);350		if (!match) return;351		const from = Number(match[1]);352		const to = Number(match[2] ?? match[1]);353		anchor = from;354		for (let n = Math.min(from, to); n <= Math.max(from, to); n++) {355			document.getElementById(`L${n}`)?.classList.add("hl");356		}357		document.getElementById(`L${Math.min(from, to)}`)?.scrollIntoView({ block: "center" });358	};359	code.addEventListener("click", (event) => {360		const link = event.target.closest(".ln");361		if (!link || !event.shiftKey || anchor === null) return;362		event.preventDefault();363		const n = Number(link.textContent);364		history.replaceState(null, "", `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`);365		mark();366	});367	window.addEventListener("hashchange", mark);368	mark();369}370371// The Atlas (§21.5): each row's detail panel waits in the <template> after it; the filter narrows372// by filename; which folders are open is remembered per repo in this browser.373function atlas() {374	const root = document.querySelector(".atlas");375	const filter = document.querySelector(".atlas-filter");376	if (!root || !filter) return;377	const panel = root.querySelector(".atlas-panel");378	const folders = [...root.querySelectorAll(".atlas-folder")];379	const files = [...root.querySelectorAll(".atlas-file")];380	// Its height is the closed tree's, fixed before any folder opens; opening one scrolls within.381	root.style.height = `${root.offsetHeight}px`;382	root.style.overflowY = "hidden";383	const key = `kepr:atlas:${root.dataset.repo}`;384	const opened = new Set(remembered(key));385	// `?open=<folder>` from a file page's breadcrumb unfolds that folder, without remembering it.386	const unfold = new URLSearchParams(location.search).get("open");387	const onPath = (path) => unfold !== null && `${unfold}/`.startsWith(`${path}/`);388	for (const folder of folders) {389		folder.open = opened.has(folder.dataset.path) || onPath(folder.dataset.path);390		folder.addEventListener("toggle", () => {391			if (filter.value.trim()) return;392			if (folder.open) opened.add(folder.dataset.path);393			else opened.delete(folder.dataset.path);394			remember(key, [...opened]);395		});396	}397	let selected = null;398	const show = (row) => {399		selected?.classList.remove("selected");400		selected = row;401		row.classList.add("selected");402		panel.replaceChildren(row.nextElementSibling.content.cloneNode(true));403	};404	for (const row of root.querySelectorAll(".atlas-row")) {405		row.addEventListener("pointerenter", (event) => {406			if (event.pointerType === "mouse") show(row);407		});408		row.addEventListener("focus", () => show(row));409	}410	const viewer = fileViewer(root);411	for (const file of files) {412		file.addEventListener("click", (event) => {413			if (modified(event)) return;414			event.preventDefault();415			viewer(file);416		});417	}418	const target = folders.find((folder) => folder.dataset.path === unfold)?.firstElementChild;419	if (target) {420		show(target);421		target.scrollIntoView({ block: "center" });422	}423	filter.addEventListener("input", () => {424		const query = filter.value.trim().toLowerCase();425		for (const file of files) file.hidden = query !== "" && !file.dataset.name.includes(query);426		for (const folder of folders) {427			folder.hidden = query !== "" && !folder.querySelector(".atlas-file:not([hidden])");428			folder.open = query !== "" || opened.has(folder.dataset.path);429		}430	});431	// The filter waits behind a search button, and folds back into it when left empty.432	const search = document.querySelector(".atlas-search-btn");433	search.addEventListener("click", () => {434		search.hidden = true;435		filter.hidden = false;436		filter.focus();437	});438	filter.addEventListener("blur", () => {439		if (filter.value.trim() !== "") return;440		filter.hidden = true;441		search.hidden = false;442	});443	search.hidden = false;444	panel.hidden = false;445}446447// Kepr 2's Atlas picker: saves filtered as you type; a click outside or Escape closes it.448function atlasPicker() {449	const picker = document.querySelector(".atlas-picker");450	if (!picker) return;451	const filter = picker.querySelector(".atlas-picker-filter");452	const rows = [...picker.querySelectorAll(".atlas-picker-row")];453	filter.addEventListener("input", () => {454		const query = filter.value.trim().toLowerCase();455		for (const row of rows) row.hidden = query !== "" && !row.dataset.q.includes(query);456	});457	picker.addEventListener("toggle", () => {458		if (picker.open) filter.focus();459	});460	document.addEventListener("click", (event) => {461		if (!picker.contains(event.target)) picker.open = false;462	});463	picker.addEventListener("keydown", (event) => {464		if (event.key === "Escape") picker.open = false;465	});466	filter.hidden = false;467}468469// Files open in a viewer over the page, as in Kepr 2's Atlas: the file page's content with the470// row's detail panel beside it, and controls for text size, wrapping, raw, download and the471// neighbouring files. Escape closes it; the arrow keys step through the files.472function fileViewer(root) {473	let [row, size, wrap] = [null, 12, false];474	const overlay = element("div", "fv-overlay");475	overlay.tabIndex = -1;476	overlay.setAttribute("role", "dialog");477	overlay.setAttribute("aria-modal", "true");478	const close = () => {479		overlay.remove();480		row?.focus();481		row = null;482	};483	const step = (by) => {484		const rows = [...root.querySelectorAll(".atlas-file")].filter((file) => !file.hidden);485		const next = rows[rows.indexOf(row) + by];486		if (next) open(next);487	};488	const restyle = () => {489		overlay.style.setProperty("--fv-size", `${size}px`);490		overlay.classList.toggle("fv-wrap", wrap);491	};492	const open = async (file, url = file.href) => {493		row = file;494		const rows = [...root.querySelectorAll(".atlas-file")].filter((each) => !each.hidden);495		const index = rows.indexOf(file);496		const detail = file.nextElementSibling.content.cloneNode(true);497		const name = detail.querySelector(".atlas-info-name").textContent;498		const path = detail.querySelector(".atlas-info-path").textContent;499		const raw = new URL(file.href);500		raw.pathname = raw.pathname.replace(/^(\/[^/]+)\/files\//, "$1/raw/");501		const button = (text, action, className = "fv-ctrl-btn") => {502			const b = element("button", className, text);503			b.type = "button";504			b.addEventListener("click", action);505			return b;506		};507		const link = (text, download) => {508			const a = element("a", "fv-ctrl-btn", text);509			a.href = raw.href;510			if (download) a.download = name;511			return a;512		};513		const source = element("div", "fv-source", "loading…");514		const panel = element("aside", "fv-detail");515		panel.append(detail);516		const header = element("div", "fv-header");517		const title = element("div", "fv-title");518		title.append(element("span", "fv-name", name), element("span", "fv-path mono", path));519		const controls = element("div", "fv-controls mono");520		controls.append(521			button("A−", () => {522				size = Math.max(9, size - 1);523				restyle();524			}),525			button("A+", () => {526				size = Math.min(20, size + 1);527				restyle();528			}),529			button("⇌ wrap", (event) => {530				wrap = !wrap;531				event.currentTarget.classList.toggle("active", wrap);532				restyle();533			}),534			link("raw", false),535			link("download", true),536		);537		controls.children[2].classList.toggle("active", wrap);538		const nav = element("div", "fv-nav mono");539		const back = button("←", () => step(-1), "fv-ctrl-btn");540		const forward = button("→", () => step(1), "fv-ctrl-btn");541		back.disabled = index <= 0;542		forward.disabled = index >= rows.length - 1;543		nav.append(back, element("span", "fv-pos", `${index + 1}/${rows.length}`), forward);544		header.append(button("×", close, "fv-close"), title, controls, nav);545		const body = element("div", "fv-body");546		body.append(source, panel);547		overlay.replaceChildren(header, body);548		restyle();549		if (!overlay.isConnected) document.body.append(overlay);550		overlay.focus();551		const main = await fetchMain(url);552		if (row !== file) return;553		if (!main) {554			location.href = url;555			return;556		}557		for (const heading of main.querySelectorAll(".breadcrumb, .file-page-title")) heading.remove();558		main.querySelector(".file-meta:not(:last-child)")?.remove();559		source.replaceChildren(...main.childNodes);560	};561	overlay.addEventListener("keydown", (event) => {562		if (event.key === "Escape") close();563		else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {564			event.preventDefault();565			step(event.key === "ArrowLeft" ? -1 : 1);566		}567	});568	// The Markdown view's rendered/source toggle points at the file page: switch in place.569	overlay.addEventListener("click", (event) => {570		const link = event.target.closest(".fv-source a[href]");571		if (!link || !row || modified(event)) return;572		if (new URL(link.href).pathname !== new URL(row.href).pathname) return;573		event.preventDefault();574		open(row, link.href);575	});576	return open;577}578579function element(tag, className, text) {580	const node = document.createElement(tag);581	node.className = className;582	if (text !== undefined) node.textContent = text;583	return node;584}585586// Storage can be missing, refused (private windows, blocked windows) or hold anything; only a587// list of folder paths is trusted, and otherwise the tree starts closed.588function remembered(key) {589	try {590		const value = JSON.parse(localStorage.getItem(key) ?? "[]");591		return Array.isArray(value) && value.every((path) => typeof path === "string") ? value : [];592	} catch {593		return [];594	}595}596597function remember(key, value) {598	try {599		localStorage.setItem(key, JSON.stringify(value));600	} catch {}601}602603// The profile's hex backdrop already breathes in CSS; this lights a few columns at a time, as604// Kepr 2's canvas did. Nothing moves under reduced motion, including when it is switched on later.605function hexGlow() {606	const columns = document.querySelectorAll(".hex-col");607	if (columns.length === 0) return;608	const reduce = matchMedia("(prefers-reduced-motion: reduce)");609	let timer = null;610	const update = () => {611		clearInterval(timer);612		for (const column of columns) column.classList.remove("glow");613		if (reduce.matches) return;614		timer = setInterval(() => {615			const column = columns[Math.floor(Math.random() * columns.length)];616			if (document.querySelectorAll(".hex-col.glow").length >= 3) return;617			column.classList.add("glow");618			setTimeout(() => column.classList.remove("glow"), 3200);619		}, 1500);620	};621	reduce.addEventListener("change", update);622	update();623}624625// A CSS duration cannot follow a track's width, so the pace would depend on the messages; set it626// here so each row slides at its own --speed in px/s, as Kepr 2's canvas did.627function tickerPace() {628	for (const track of document.querySelectorAll(".ticker-track")) {629		const speed = Number(getComputedStyle(track).getPropertyValue("--speed"));630		if (speed > 0) track.style.animationDuration = `${track.scrollWidth / 2 / speed}s`;631	}632}633634// Widths are only final once Martian Mono has loaded.635document.fonts.ready.then(tickerPace);636timeline();637copyButtons(document);638panels();639saveLists();640atlasPicker();641lineRanges();642atlas();643hexGlow();