kepr.js

22.1 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		height = Math.min(320, Math.max(120, 120 + Math.floor(Math.log2(saves.length + 1) * 30)));63		canvas.width = Math.round(width * devicePixelRatio);64		canvas.height = Math.round(height * devicePixelRatio);65		canvas.style.width = `${width}px`;66		canvas.style.height = `${height}px`;67		ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);68		strip = Math.max(width * 3, saves.length * 48);69		radius = Math.min(5, height * 0.022);70		let previous = Number.NEGATIVE_INFINITY;71		for (const save of saves) {72			save.x = Math.max(xOf(save.t), previous + radius + 8);73			previous = save.x;74		}75		offset = Math.max(0, previous - width * 0.82);76	};77	// Every visible copy of each x on the looping strip.78	const onScreen = (x, margin) => {79		const shift = ((offset % strip) + strip) % strip;80		return [-1, 0, 1]81			.map((k) => x - shift + k * strip)82			.filter((sx) => sx > -margin && sx < width + margin);83	};84	const hit = (event) => {85		const box = canvas.getBoundingClientRect();86		const [mx, my] = [event.clientX - box.left, event.clientY - box.top];87		if (Math.abs(my - height / 2) > radius + 7) return null;88		return (89			saves.find((save) => onScreen(save.x, 0).some((x) => Math.abs(mx - x) < radius + 7)) ?? null90		);91	};92	let [hovered, drag, dragFrom, downX, lastX, velocity, resumeAt, lastFrame] = [93		null,94		null,95		0,96		0,97		0,98		0,99		0,100		0,101	];102	const hover = (save, event) => {103		if (save !== hovered) {104			hovered = save;105			tip.hidden = save === null;106			if (save)107				tip.replaceChildren(108					line("tip-id", save.id),109					line("tip-msg", save.message),110					line("tip-meta", save.meta),111				);112		}113		if (save) {114			const left = event.clientX + 14;115			tip.style.left = `${left + 340 > innerWidth ? Math.max(8, event.clientX - 354) : left}px`;116			tip.style.top = `${event.clientY - 10}px`;117		}118		canvas.style.cursor = save ? "pointer" : drag === null ? "grab" : "grabbing";119	};120	const draw = (time) => {121		const dt = lastFrame ? Math.min((time - lastFrame) / 1000, 0.1) : 0;122		lastFrame = time;123		if (drag === null && hovered === null) {124			if (Math.abs(velocity) > 0.5) {125				offset -= velocity;126				velocity *= 0.92;127			} else if (!reduce.matches && time >= resumeAt) offset += 20 * dt;128		}129		const y = height / 2;130		ctx.clearRect(0, 0, width, height);131		ctx.lineWidth = 1;132		ctx.strokeStyle = css.getPropertyValue("--border");133		const years = [new Date(start * 1000).getFullYear(), new Date(end * 1000).getFullYear()];134		for (let year = years[0]; year <= years[1]; year++) {135			for (const x of onScreen(xOf(new Date(year, 0, 1) / 1000), 0)) {136				ctx.beginPath();137				ctx.moveTo(x, height * 0.1);138				ctx.lineTo(x, height * 0.9);139				ctx.stroke();140			}141		}142		ctx.globalAlpha = 0.7;143		ctx.strokeStyle = css.getPropertyValue("--text-muted");144		ctx.setLineDash([4, 4]);145		ctx.beginPath();146		ctx.moveTo(0, y);147		ctx.lineTo(width, y);148		ctx.stroke();149		ctx.setLineDash([]);150		ctx.font = '10px "Martian Mono", monospace';151		ctx.textAlign = "center";152		for (const save of saves) {153			for (const x of onScreen(save.x, 10)) {154				ctx.globalAlpha = hovered === null ? save.alpha : hovered === save ? 1 : save.alpha * 0.6;155				ctx.fillStyle = save.fill;156				ctx.beginPath();157				ctx.arc(x, y, radius, 0, Math.PI * 2);158				ctx.fill();159				if (hovered === save) {160					ctx.fillStyle = css.getPropertyValue("--accent");161					ctx.fillText(save.id.slice(0, 6), x, y - radius - 6);162				}163			}164		}165		ctx.globalAlpha = 1;166		requestAnimationFrame(draw);167	};168	canvas.addEventListener("pointerdown", (event) => {169		[drag, dragFrom, downX, lastX, velocity] = [170			event.clientX,171			offset,172			event.clientX,173			event.clientX,174			0,175		];176		canvas.setPointerCapture(event.pointerId);177	});178	canvas.addEventListener("pointermove", (event) => {179		if (drag !== null) {180			offset = dragFrom - (event.clientX - drag);181			velocity = velocity * 0.6 + (event.clientX - lastX) * 0.4;182			lastX = event.clientX;183		}184		if (event.pointerType === "mouse") hover(hit(event), event);185	});186	const release = () => {187		drag = null;188		resumeAt = performance.now() + 900;189	};190	canvas.addEventListener("pointerup", (event) => {191		release();192		const save = Math.abs(event.clientX - downX) > 4 ? null : hit(event);193		if (save) location.href = save.href;194	});195	canvas.addEventListener("pointercancel", release);196	canvas.addEventListener("pointerleave", (event) => {197		if (drag === null) hover(null, event);198	});199	addEventListener("resize", resize);200	resize();201	requestAnimationFrame(draw);202}203204function line(className, text) {205	const div = document.createElement("div");206	div.className = className;207	div.textContent = text ?? "";208	return div;209}210211function copyButtons(root) {212	for (const button of root.querySelectorAll("button[data-copy]")) {213		const label = button.textContent;214		button.hidden = false;215		button.addEventListener("click", async () => {216			await navigator.clipboard.writeText(button.dataset.copy);217			button.textContent = "copied";218			button.classList.add("copied");219			setTimeout(() => {220				button.textContent = label;221				button.classList.remove("copied");222			}, 1400);223		});224	}225}226227// A Kepr page's <main>, its links made absolute so they point where they did on their own page;228// null if it could not be had, and the caller then goes to the page itself.229async function fetchMain(url) {230	try {231		const response = await fetch(url);232		if (!response.ok) return null;233		const page = new DOMParser().parseFromString(await response.text(), "text/html");234		const main = page.querySelector("main");235		for (const link of main.querySelectorAll("a[href]:not([href^='#'])")) {236			link.href = new URL(link.getAttribute("href"), url).href;237		}238		return main;239	} catch {240		return null;241	}242}243244// A click the browser should handle itself: a new tab or window, or a download.245function modified(event) {246	return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;247}248249// §21.9's panels open inline under the tabs, as Kepr 2's did: the panel page without its heading.250// A link back to the same page (the issue tabs) loads in place; any other link navigates.251function panels() {252	const host = document.querySelector(".repo-panel");253	if (!host) return;254	let open = null;255	let wanted = "";256	const load = async (url) => {257		wanted = url;258		const main = await fetchMain(url);259		if (wanted !== url) return;260		if (!main) {261			location.href = url;262			return;263		}264		main.querySelector(".breadcrumb")?.remove();265		main.querySelector(".file-page-title")?.remove();266		host.replaceChildren(...main.childNodes);267		copyButtons(host);268		host.hidden = false;269	};270	for (const tab of document.querySelectorAll(".repo-tab[data-panel]")) {271		tab.addEventListener("click", (event) => {272			if (modified(event)) return;273			event.preventDefault();274			open?.classList.remove("active");275			if (open === tab) {276				open = null;277				wanted = "";278				host.hidden = true;279				return;280			}281			open = tab;282			tab.classList.add("active");283			load(tab.href);284		});285	}286	host.addEventListener("click", (event) => {287		const link = event.target.closest("a[href]");288		if (!link || !open || modified(event)) return;289		if (new URL(link.href).pathname !== new URL(open.href).pathname) return;290		event.preventDefault();291		load(link.href);292	});293}294295// History and the Atlas's save picker hold one page of saves; scrolling to the end of either296// brings in the next page's rows. The pager is for pages without JavaScript.297function saveLists() {298	for (const list of document.querySelectorAll(".save-list")) {299		const observer = new IntersectionObserver(300			async ([entry]) => {301				const older = list.querySelector(".pager-older");302				if (!entry?.isIntersecting || !older) return;303				observer.disconnect();304				const main = await fetchMain(older.href);305				const next = main?.querySelector(`.save-list[data-list="${list.dataset.list}"]`);306				if (!next) return;307				next.querySelector(".pager a:not(.pager-older)")?.remove();308				list.querySelector(".pager").replaceWith(...next.childNodes);309				watch();310			},311			{ root: list },312		);313		const watch = () => {314			const older = list.querySelector(".pager-older");315			if (older) observer.observe(older);316		};317		watch();318	}319}320321// `#L42` works through CSS :target alone; a range `#L42-L50` needs marking line by line.322function lineRanges() {323	const code = document.querySelector(".code");324	if (!code) return;325	let anchor = null;326	const mark = () => {327		for (const marked of code.querySelectorAll(".line.hl")) marked.classList.remove("hl");328		const match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);329		if (!match) return;330		const from = Number(match[1]);331		const to = Number(match[2] ?? match[1]);332		anchor = from;333		for (let n = Math.min(from, to); n <= Math.max(from, to); n++) {334			document.getElementById(`L${n}`)?.classList.add("hl");335		}336		document.getElementById(`L${Math.min(from, to)}`)?.scrollIntoView({ block: "center" });337	};338	code.addEventListener("click", (event) => {339		const link = event.target.closest(".ln");340		if (!link || !event.shiftKey || anchor === null) return;341		event.preventDefault();342		const n = Number(link.textContent);343		history.replaceState(null, "", `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`);344		mark();345	});346	window.addEventListener("hashchange", mark);347	mark();348}349350// The Atlas (§21.5): each row's detail panel waits in the <template> after it; the filter narrows351// by filename; which folders are open is remembered per repo in this browser.352function atlas() {353	const root = document.querySelector(".atlas");354	const filter = document.querySelector(".atlas-filter");355	if (!root || !filter) return;356	const panel = root.querySelector(".atlas-panel");357	const folders = [...root.querySelectorAll(".atlas-folder")];358	const files = [...root.querySelectorAll(".atlas-file")];359	const key = `kepr:atlas:${root.dataset.repo}`;360	const opened = new Set(remembered(key));361	// `?open=<folder>` from a file page's breadcrumb unfolds that folder, without remembering it.362	const unfold = new URLSearchParams(location.search).get("open");363	const onPath = (path) => unfold !== null && `${unfold}/`.startsWith(`${path}/`);364	for (const folder of folders) {365		folder.open = opened.has(folder.dataset.path) || onPath(folder.dataset.path);366		folder.addEventListener("toggle", () => {367			if (filter.value.trim()) return;368			if (folder.open) opened.add(folder.dataset.path);369			else opened.delete(folder.dataset.path);370			remember(key, [...opened]);371		});372	}373	let selected = null;374	const show = (row) => {375		selected?.classList.remove("selected");376		selected = row;377		row.classList.add("selected");378		panel.replaceChildren(row.nextElementSibling.content.cloneNode(true));379	};380	for (const row of root.querySelectorAll(".atlas-row")) {381		row.addEventListener("pointerenter", (event) => {382			if (event.pointerType === "mouse") show(row);383		});384		row.addEventListener("focus", () => show(row));385	}386	const viewer = fileViewer(root);387	for (const file of files) {388		file.addEventListener("click", (event) => {389			if (modified(event)) return;390			event.preventDefault();391			viewer(file);392		});393	}394	const target = folders.find((folder) => folder.dataset.path === unfold)?.firstElementChild;395	if (target) {396		show(target);397		target.scrollIntoView({ block: "center" });398	}399	filter.addEventListener("input", () => {400		const query = filter.value.trim().toLowerCase();401		for (const file of files) file.hidden = query !== "" && !file.dataset.name.includes(query);402		for (const folder of folders) {403			folder.hidden = query !== "" && !folder.querySelector(".atlas-file:not([hidden])");404			folder.open = query !== "" || opened.has(folder.dataset.path);405		}406	});407	// The filter waits behind a search button, and folds back into it when left empty.408	const search = document.querySelector(".atlas-search-btn");409	search.addEventListener("click", () => {410		search.hidden = true;411		filter.hidden = false;412		filter.focus();413	});414	filter.addEventListener("blur", () => {415		if (filter.value.trim() !== "") return;416		filter.hidden = true;417		search.hidden = false;418	});419	search.hidden = false;420	panel.hidden = false;421}422423// Kepr 2's Atlas picker: saves or versions, filtered as you type; a click outside or Escape424// closes it.425function atlasPicker() {426	const picker = document.querySelector(".atlas-picker");427	if (!picker) return;428	const tabs = picker.querySelector(".atlas-picker-tabs");429	const filter = picker.querySelector(".atlas-picker-filter");430	const lists = [...picker.querySelectorAll(".atlas-picker-list")];431	let shown = lists[0];432	const narrow = () => {433		const query = filter.value.trim().toLowerCase();434		for (const row of shown.children) row.hidden = query !== "" && !row.dataset.q.includes(query);435	};436	for (const tab of tabs.children) {437		tab.addEventListener("click", () => {438			for (const each of tabs.children) each.classList.toggle("active", each === tab);439			shown = lists.find((list) => list.dataset.tab === tab.dataset.tab);440			for (const list of lists) list.hidden = list !== shown;441			filter.placeholder = `filter ${tab.dataset.tab}`;442			filter.value = "";443			narrow();444			filter.focus();445		});446	}447	filter.addEventListener("input", narrow);448	picker.addEventListener("toggle", () => {449		if (picker.open) filter.focus();450	});451	document.addEventListener("click", (event) => {452		if (!picker.contains(event.target)) picker.open = false;453	});454	picker.addEventListener("keydown", (event) => {455		if (event.key === "Escape") picker.open = false;456	});457	tabs.hidden = false;458	filter.hidden = false;459}460461// Files open in a viewer over the page, as in Kepr 2's Atlas: the file page's content with the462// row's detail panel beside it, and controls for text size, wrapping, raw, download and the463// neighbouring files. Escape closes it; the arrow keys step through the files.464function fileViewer(root) {465	let [row, size, wrap] = [null, 12, false];466	const overlay = element("div", "fv-overlay");467	overlay.tabIndex = -1;468	overlay.setAttribute("role", "dialog");469	overlay.setAttribute("aria-modal", "true");470	const close = () => {471		overlay.remove();472		row?.focus();473		row = null;474	};475	const step = (by) => {476		const rows = [...root.querySelectorAll(".atlas-file")].filter((file) => !file.hidden);477		const next = rows[rows.indexOf(row) + by];478		if (next) open(next);479	};480	const restyle = () => {481		overlay.style.setProperty("--fv-size", `${size}px`);482		overlay.classList.toggle("fv-wrap", wrap);483	};484	const open = async (file, url = file.href) => {485		row = file;486		const rows = [...root.querySelectorAll(".atlas-file")].filter((each) => !each.hidden);487		const index = rows.indexOf(file);488		const detail = file.nextElementSibling.content.cloneNode(true);489		const name = detail.querySelector(".atlas-info-name").textContent;490		const path = detail.querySelector(".atlas-info-path").textContent;491		const raw = new URL(file.href);492		raw.pathname = raw.pathname.replace(/^(\/[^/]+)\/files\//, "$1/raw/");493		const button = (text, action, className = "fv-ctrl-btn") => {494			const b = element("button", className, text);495			b.type = "button";496			b.addEventListener("click", action);497			return b;498		};499		const link = (text, download) => {500			const a = element("a", "fv-ctrl-btn", text);501			a.href = raw.href;502			if (download) a.download = name;503			return a;504		};505		const source = element("div", "fv-source", "loading…");506		const panel = element("aside", "fv-detail");507		panel.append(detail);508		const header = element("div", "fv-header");509		const title = element("div", "fv-title");510		title.append(element("span", "fv-name", name), element("span", "fv-path mono", path));511		const controls = element("div", "fv-controls mono");512		controls.append(513			button("A−", () => {514				size = Math.max(9, size - 1);515				restyle();516			}),517			button("A+", () => {518				size = Math.min(20, size + 1);519				restyle();520			}),521			button("⇌ wrap", (event) => {522				wrap = !wrap;523				event.currentTarget.classList.toggle("active", wrap);524				restyle();525			}),526			link("raw", false),527			link("download", true),528		);529		controls.children[2].classList.toggle("active", wrap);530		const nav = element("div", "fv-nav mono");531		const back = button("←", () => step(-1), "fv-ctrl-btn");532		const forward = button("→", () => step(1), "fv-ctrl-btn");533		back.disabled = index <= 0;534		forward.disabled = index >= rows.length - 1;535		nav.append(back, element("span", "fv-pos", `${index + 1}/${rows.length}`), forward);536		header.append(button("×", close, "fv-close"), title, controls, nav);537		const body = element("div", "fv-body");538		body.append(source, panel);539		overlay.replaceChildren(header, body);540		restyle();541		if (!overlay.isConnected) document.body.append(overlay);542		overlay.focus();543		const main = await fetchMain(url);544		if (row !== file) return;545		if (!main) {546			location.href = url;547			return;548		}549		for (const heading of main.querySelectorAll(".breadcrumb, .file-page-title")) heading.remove();550		main.querySelector(".file-meta:not(:last-child)")?.remove();551		source.replaceChildren(...main.childNodes);552	};553	overlay.addEventListener("keydown", (event) => {554		if (event.key === "Escape") close();555		else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {556			event.preventDefault();557			step(event.key === "ArrowLeft" ? -1 : 1);558		}559	});560	// The Markdown view's rendered/source toggle points at the file page: switch in place.561	overlay.addEventListener("click", (event) => {562		const link = event.target.closest(".fv-source a[href]");563		if (!link || !row || modified(event)) return;564		if (new URL(link.href).pathname !== new URL(row.href).pathname) return;565		event.preventDefault();566		open(row, link.href);567	});568	return open;569}570571function element(tag, className, text) {572	const node = document.createElement(tag);573	node.className = className;574	if (text !== undefined) node.textContent = text;575	return node;576}577578// Storage can be missing, refused (private windows, blocked windows) or hold anything; only a579// list of folder paths is trusted, and otherwise the tree starts closed.580function remembered(key) {581	try {582		const value = JSON.parse(localStorage.getItem(key) ?? "[]");583		return Array.isArray(value) && value.every((path) => typeof path === "string") ? value : [];584	} catch {585		return [];586	}587}588589function remember(key, value) {590	try {591		localStorage.setItem(key, JSON.stringify(value));592	} catch {}593}594595// The profile's hex backdrop already breathes in CSS; this lights a few columns at a time, as596// Kepr 2's canvas did. Nothing moves under reduced motion, including when it is switched on later.597function hexGlow() {598	const columns = document.querySelectorAll(".hex-col");599	if (columns.length === 0) return;600	const reduce = matchMedia("(prefers-reduced-motion: reduce)");601	let timer = null;602	const update = () => {603		clearInterval(timer);604		for (const column of columns) column.classList.remove("glow");605		if (reduce.matches) return;606		timer = setInterval(() => {607			const column = columns[Math.floor(Math.random() * columns.length)];608			if (document.querySelectorAll(".hex-col.glow").length >= 3) return;609			column.classList.add("glow");610			setTimeout(() => column.classList.remove("glow"), 3200);611		}, 1500);612	};613	reduce.addEventListener("change", update);614	update();615}616617// A CSS duration cannot follow a track's width, so the pace would depend on the messages; set it618// here so each row slides at its own --speed in px/s, as Kepr 2's canvas did.619function tickerPace() {620	for (const track of document.querySelectorAll(".ticker-track")) {621		const speed = Number(getComputedStyle(track).getPropertyValue("--speed"));622		if (speed > 0) track.style.animationDuration = `${track.scrollWidth / 2 / speed}s`;623	}624}625626// Widths are only final once Martian Mono has loaded.627document.fonts.ready.then(tickerPace);628timeline();629copyButtons(document);630panels();631saveLists();632atlasPicker();633lineRanges();634atlas();635hexGlow();