kepr.js

21.5 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 page;229// null if it could not be had, and the caller then goes to the page itself.230async function fetchMain(url) {231	try {232		const response = await fetch(url);233		if (!response.ok) return null;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"), 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// A link back to the same page (the issue tabs) loads in place; any other link navigates.252function panels() {253	const host = document.querySelector(".repo-panel");254	if (!host) return;255	let open = null;256	let wanted = "";257	const load = async (url) => {258		wanted = url;259		const main = await fetchMain(url);260		if (wanted !== url) return;261		if (!main) {262			location.href = url;263			return;264		}265		main.querySelector(".breadcrumb")?.remove();266		main.querySelector(".file-page-title")?.remove();267		host.replaceChildren(...main.childNodes);268		copyButtons(host);269		host.hidden = false;270	};271	for (const tab of document.querySelectorAll(".repo-tab[data-panel]")) {272		tab.addEventListener("click", (event) => {273			if (modified(event)) return;274			event.preventDefault();275			open?.classList.remove("active");276			if (open === tab) {277				open = null;278				wanted = "";279				host.hidden = true;280				return;281			}282			open = tab;283			tab.classList.add("active");284			load(tab.href);285		});286	}287	host.addEventListener("click", (event) => {288		const link = event.target.closest("a[href]");289		if (!link || !open || modified(event)) return;290		if (new URL(link.href).pathname !== new URL(open.href).pathname) return;291		event.preventDefault();292		load(link.href);293	});294}295296// History and the Atlas's save picker hold one page of saves; scrolling to the end of either297// brings in the next page's rows. The pager is for pages without JavaScript.298function saveLists() {299	for (const list of document.querySelectorAll(".save-list")) {300		const observer = new IntersectionObserver(301			async ([entry]) => {302				const older = list.querySelector(".pager-older");303				if (!entry?.isIntersecting || !older) return;304				observer.disconnect();305				const main = await fetchMain(older.href);306				const next = main?.querySelector(`.save-list[data-list="${list.dataset.list}"]`);307				if (!next) return;308				next.querySelector(".pager a:not(.pager-older)")?.remove();309				list.querySelector(".pager").replaceWith(...next.childNodes);310				watch();311			},312			{ root: list },313		);314		const watch = () => {315			const older = list.querySelector(".pager-older");316			if (older) observer.observe(older);317		};318		watch();319	}320}321322// `#L42` works through CSS :target alone; a range `#L42-L50` needs marking line by line.323function lineRanges() {324	const code = document.querySelector(".code");325	if (!code) return;326	let anchor = null;327	const mark = () => {328		for (const marked of code.querySelectorAll(".line.hl")) marked.classList.remove("hl");329		const match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);330		if (!match) return;331		const from = Number(match[1]);332		const to = Number(match[2] ?? match[1]);333		anchor = from;334		for (let n = Math.min(from, to); n <= Math.max(from, to); n++) {335			document.getElementById(`L${n}`)?.classList.add("hl");336		}337		document.getElementById(`L${Math.min(from, to)}`)?.scrollIntoView({ block: "center" });338	};339	code.addEventListener("click", (event) => {340		const link = event.target.closest(".ln");341		if (!link || !event.shiftKey || anchor === null) return;342		event.preventDefault();343		const n = Number(link.textContent);344		history.replaceState(null, "", `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`);345		mark();346	});347	window.addEventListener("hashchange", mark);348	mark();349}350351// The Atlas (§21.5): each row's detail panel waits in the <template> after it; the filter narrows352// by filename; which folders are open is remembered per repo in this browser.353function atlas() {354	const root = document.querySelector(".atlas");355	const filter = document.querySelector(".atlas-filter");356	if (!root || !filter) return;357	const panel = root.querySelector(".atlas-panel");358	const folders = [...root.querySelectorAll(".atlas-folder")];359	const files = [...root.querySelectorAll(".atlas-file")];360	const key = `kepr:atlas:${root.dataset.repo}`;361	const opened = new Set(remembered(key));362	// `?open=<folder>` from a file page's breadcrumb unfolds that folder, without remembering it.363	const unfold = new URLSearchParams(location.search).get("open");364	const onPath = (path) => unfold !== null && `${unfold}/`.startsWith(`${path}/`);365	for (const folder of folders) {366		folder.open = opened.has(folder.dataset.path) || onPath(folder.dataset.path);367		folder.addEventListener("toggle", () => {368			if (filter.value.trim()) return;369			if (folder.open) opened.add(folder.dataset.path);370			else opened.delete(folder.dataset.path);371			remember(key, [...opened]);372		});373	}374	let selected = null;375	const show = (row) => {376		selected?.classList.remove("selected");377		selected = row;378		row.classList.add("selected");379		panel.replaceChildren(row.nextElementSibling.content.cloneNode(true));380	};381	for (const row of root.querySelectorAll(".atlas-row")) {382		row.addEventListener("pointerenter", (event) => {383			if (event.pointerType === "mouse") show(row);384		});385		row.addEventListener("focus", () => show(row));386	}387	const viewer = fileViewer(root);388	for (const file of files) {389		file.addEventListener("click", (event) => {390			if (modified(event)) return;391			event.preventDefault();392			viewer(file);393		});394	}395	const target = folders.find((folder) => folder.dataset.path === unfold)?.firstElementChild;396	if (target) {397		show(target);398		target.scrollIntoView({ block: "center" });399	}400	filter.addEventListener("input", () => {401		const query = filter.value.trim().toLowerCase();402		for (const file of files) file.hidden = query !== "" && !file.dataset.name.includes(query);403		for (const folder of folders) {404			folder.hidden = query !== "" && !folder.querySelector(".atlas-file:not([hidden])");405			folder.open = query !== "" || opened.has(folder.dataset.path);406		}407	});408	// The filter waits behind a search button, and folds back into it when left empty.409	const search = document.querySelector(".atlas-search-btn");410	search.addEventListener("click", () => {411		search.hidden = true;412		filter.hidden = false;413		filter.focus();414	});415	filter.addEventListener("blur", () => {416		if (filter.value.trim() !== "") return;417		filter.hidden = true;418		search.hidden = false;419	});420	search.hidden = false;421	panel.hidden = false;422}423424// Kepr 2's Atlas picker: saves filtered as you type; a click outside or Escape closes it.425function atlasPicker() {426	const picker = document.querySelector(".atlas-picker");427	if (!picker) return;428	const filter = picker.querySelector(".atlas-picker-filter");429	const rows = [...picker.querySelectorAll(".atlas-picker-row")];430	filter.addEventListener("input", () => {431		const query = filter.value.trim().toLowerCase();432		for (const row of rows) row.hidden = query !== "" && !row.dataset.q.includes(query);433	});434	picker.addEventListener("toggle", () => {435		if (picker.open) filter.focus();436	});437	document.addEventListener("click", (event) => {438		if (!picker.contains(event.target)) picker.open = false;439	});440	picker.addEventListener("keydown", (event) => {441		if (event.key === "Escape") picker.open = false;442	});443	filter.hidden = false;444}445446// Files open in a viewer over the page, as in Kepr 2's Atlas: the file page's content with the447// row's detail panel beside it, and controls for text size, wrapping, raw, download and the448// neighbouring files. Escape closes it; the arrow keys step through the files.449function fileViewer(root) {450	let [row, size, wrap] = [null, 12, false];451	const overlay = element("div", "fv-overlay");452	overlay.tabIndex = -1;453	overlay.setAttribute("role", "dialog");454	overlay.setAttribute("aria-modal", "true");455	const close = () => {456		overlay.remove();457		row?.focus();458		row = null;459	};460	const step = (by) => {461		const rows = [...root.querySelectorAll(".atlas-file")].filter((file) => !file.hidden);462		const next = rows[rows.indexOf(row) + by];463		if (next) open(next);464	};465	const restyle = () => {466		overlay.style.setProperty("--fv-size", `${size}px`);467		overlay.classList.toggle("fv-wrap", wrap);468	};469	const open = async (file, url = file.href) => {470		row = file;471		const rows = [...root.querySelectorAll(".atlas-file")].filter((each) => !each.hidden);472		const index = rows.indexOf(file);473		const detail = file.nextElementSibling.content.cloneNode(true);474		const name = detail.querySelector(".atlas-info-name").textContent;475		const path = detail.querySelector(".atlas-info-path").textContent;476		const raw = new URL(file.href);477		raw.pathname = raw.pathname.replace(/^(\/[^/]+)\/files\//, "$1/raw/");478		const button = (text, action, className = "fv-ctrl-btn") => {479			const b = element("button", className, text);480			b.type = "button";481			b.addEventListener("click", action);482			return b;483		};484		const link = (text, download) => {485			const a = element("a", "fv-ctrl-btn", text);486			a.href = raw.href;487			if (download) a.download = name;488			return a;489		};490		const source = element("div", "fv-source", "loading…");491		const panel = element("aside", "fv-detail");492		panel.append(detail);493		const header = element("div", "fv-header");494		const title = element("div", "fv-title");495		title.append(element("span", "fv-name", name), element("span", "fv-path mono", path));496		const controls = element("div", "fv-controls mono");497		controls.append(498			button("A−", () => {499				size = Math.max(9, size - 1);500				restyle();501			}),502			button("A+", () => {503				size = Math.min(20, size + 1);504				restyle();505			}),506			button("⇌ wrap", (event) => {507				wrap = !wrap;508				event.currentTarget.classList.toggle("active", wrap);509				restyle();510			}),511			link("raw", false),512			link("download", true),513		);514		controls.children[2].classList.toggle("active", wrap);515		const nav = element("div", "fv-nav mono");516		const back = button("←", () => step(-1), "fv-ctrl-btn");517		const forward = button("→", () => step(1), "fv-ctrl-btn");518		back.disabled = index <= 0;519		forward.disabled = index >= rows.length - 1;520		nav.append(back, element("span", "fv-pos", `${index + 1}/${rows.length}`), forward);521		header.append(button("×", close, "fv-close"), title, controls, nav);522		const body = element("div", "fv-body");523		body.append(source, panel);524		overlay.replaceChildren(header, body);525		restyle();526		if (!overlay.isConnected) document.body.append(overlay);527		overlay.focus();528		const main = await fetchMain(url);529		if (row !== file) return;530		if (!main) {531			location.href = url;532			return;533		}534		for (const heading of main.querySelectorAll(".breadcrumb, .file-page-title")) heading.remove();535		main.querySelector(".file-meta:not(:last-child)")?.remove();536		source.replaceChildren(...main.childNodes);537	};538	overlay.addEventListener("keydown", (event) => {539		if (event.key === "Escape") close();540		else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {541			event.preventDefault();542			step(event.key === "ArrowLeft" ? -1 : 1);543		}544	});545	// The Markdown view's rendered/source toggle points at the file page: switch in place.546	overlay.addEventListener("click", (event) => {547		const link = event.target.closest(".fv-source a[href]");548		if (!link || !row || modified(event)) return;549		if (new URL(link.href).pathname !== new URL(row.href).pathname) return;550		event.preventDefault();551		open(row, link.href);552	});553	return open;554}555556function element(tag, className, text) {557	const node = document.createElement(tag);558	node.className = className;559	if (text !== undefined) node.textContent = text;560	return node;561}562563// Storage can be missing, refused (private windows, blocked windows) or hold anything; only a564// list of folder paths is trusted, and otherwise the tree starts closed.565function remembered(key) {566	try {567		const value = JSON.parse(localStorage.getItem(key) ?? "[]");568		return Array.isArray(value) && value.every((path) => typeof path === "string") ? value : [];569	} catch {570		return [];571	}572}573574function remember(key, value) {575	try {576		localStorage.setItem(key, JSON.stringify(value));577	} catch {}578}579580// The profile's hex backdrop already breathes in CSS; this lights a few columns at a time, as581// Kepr 2's canvas did. Nothing moves under reduced motion, including when it is switched on later.582function hexGlow() {583	const columns = document.querySelectorAll(".hex-col");584	if (columns.length === 0) return;585	const reduce = matchMedia("(prefers-reduced-motion: reduce)");586	let timer = null;587	const update = () => {588		clearInterval(timer);589		for (const column of columns) column.classList.remove("glow");590		if (reduce.matches) return;591		timer = setInterval(() => {592			const column = columns[Math.floor(Math.random() * columns.length)];593			if (document.querySelectorAll(".hex-col.glow").length >= 3) return;594			column.classList.add("glow");595			setTimeout(() => column.classList.remove("glow"), 3200);596		}, 1500);597	};598	reduce.addEventListener("change", update);599	update();600}601602// A CSS duration cannot follow a track's width, so the pace would depend on the messages; set it603// here so each row slides at its own --speed in px/s, as Kepr 2's canvas did.604function tickerPace() {605	for (const track of document.querySelectorAll(".ticker-track")) {606		const speed = Number(getComputedStyle(track).getPropertyValue("--speed"));607		if (speed > 0) track.style.animationDuration = `${track.scrollWidth / 2 / speed}s`;608	}609}610611// Widths are only final once Martian Mono has loaded.612document.fonts.ready.then(tickerPace);613timeline();614copyButtons(document);615panels();616saveLists();617atlasPicker();618lineRanges();619atlas();620hexGlow();