const filesContainer = document.querySelector(".files")!; // push the scrollbar to the end of the view. let distanceFromEnd = 0; let fakeScrollToEnd: number | null = null; filesContainer.scrollLeft = filesContainer.scrollWidth - filesContainer.clientWidth; window.addEventListener("resize", () => { if (fakeScrollToEnd) return; const { scrollWidth, clientWidth } = filesContainer; if (scrollWidth <= clientWidth) { distanceFromEnd = 0; } else { filesContainer.scrollLeft = scrollWidth - clientWidth - distanceFromEnd; } }, { passive: false }); let lastScrollLeft = filesContainer.scrollLeft; filesContainer.addEventListener("scroll", (ev) => { const { scrollWidth, scrollLeft, clientWidth } = filesContainer; if (scrollWidth <= clientWidth) { distanceFromEnd = 0; } else { distanceFromEnd = scrollWidth - scrollLeft - clientWidth; if (fakeScrollToEnd && scrollLeft < lastScrollLeft) { cancelAnimationFrame(fakeScrollToEnd); fakeScrollToEnd = null; } } lastScrollLeft = scrollLeft; }); const lerp = (a: number, b: number, t: number) => a + t * (b - a); function snapToEnd(initScrollStart: number) { if (fakeScrollToEnd) { cancelAnimationFrame(fakeScrollToEnd); } let lastTime = performance.now(); let scrollStart = initScrollStart ?? filesContainer.scrollLeft; if (scrollStart >= filesContainer.scrollWidth) { return; } fakeScrollToEnd = requestAnimationFrame(function tick() { const now = performance.now(); const dt = now - lastTime; lastTime = now; const f = 1 - (0.98 ** dt); const { scrollWidth, clientWidth } = filesContainer; const target = Math.floor(scrollWidth - clientWidth); scrollStart = lerp(scrollStart, target, f); filesContainer.scrollLeft = scrollStart; if (Math.abs(scrollStart - target) < 0.2) { fakeScrollToEnd = null; } else { fakeScrollToEnd = requestAnimationFrame(tick); } }); } interface CacheEntry { html: string; expires: number; } // It is intentional that the existing page is NOT put into the cache. This is // just to avoid the differences between the partials and the full page // (subtle differences in activeFilename & isLast) let currentFile: string = location.pathname.replace(/^\/file/, ""); let navigationId = 0; const cache = new Map(); const prefetching = new Map>(); const fetchLater: string[] = []; let hasCotyledonSpeedbump = false; function prefetchEntry( filePath: string, lazy = false, ): void | Promise { console.assert(filePath[0] === "/", "filePath must start with a /"); const existingEntry = cache.get(filePath); if (existingEntry) { if (existingEntry.expires > Date.now()) { return; } cache.delete(filePath); } const existingPromise = prefetching.get(filePath); if (existingPromise) return existingPromise; // lazy prefetches should be limited if (lazy && prefetching.size > 2) { if (!fetchLater.includes(filePath)) { fetchLater.push(filePath); } return; } if (filePath === "/cotyledon") { ensureCanvasReady("cotyledon"); } const promise = fetch(`/file${filePath}$partial`) .then((resp) => { if (resp.status !== 200) { throw new Error(`Failed to fetch ${filePath}`); } return resp.text(); }) .then((html) => { const entry: CacheEntry = { html, expires: Date.now() + 1000 * 60 * 20 }; cache.set(filePath, entry); prefetching.delete(filePath); if (fetchLater.length > 0 && prefetching.size < 2) { const filePath = fetchLater.shift()!; prefetchEntry(filePath, false); } return entry; }); prefetching.set(filePath, promise); return promise; } function fetchEntry(filePath: string): Promise { const pf = prefetchEntry(filePath); if (pf) return pf; return Promise.resolve(cache.get(filePath)!); } type CanvasFn = (canvas: HTMLCanvasElement, panel: HTMLElement) => void; const fetchCanvas = new Map>(); function ensureCanvasReady(id: string): Promise { let func = (globalThis as any)["canvas_" + id]; if (func) return Promise.resolve(func); let promise = fetchCanvas.get(id); if (promise) return promise; let resolve: (c: CanvasFn) => void; promise = new Promise((r) => resolve = r); fetchCanvas.set(id, promise); const script = document.createElement("script"); script.src = `/js/canvas/${id}.js`; script.async = true; script.onload = () => { func = (globalThis as any)["canvas_" + id]; fetchCanvas.delete(id); if (func) { resolve(func); } else { console.error(`Error loading canvas script: ${id}`); } }; script.onerror = () => { console.error(`Error loading canvas script: ${id}`); }; document.head.appendChild(script); return promise; } interface Tooltip { tooltip: HTMLElement; top: number; index: number; } const panels: Panel[] = []; class Panel { index: number; panel: HTMLElement; content: HTMLElement; width: number; tooltips: Tooltip[] | null; linkFlags: number[] | null = null; basenames: string[] | null = null; unmountCanvas: (() => void) | null = null; constructor(panel: HTMLElement, index: number) { console.assert(panel.classList.contains("panel")); this.panel = panel; this.index = index; this.content = panel.querySelector(".content.primary")!; if (index === 0) { this.panel.classList.add("first"); } const canvas = panel.querySelector( "canvas[data-canvas]", ) as HTMLCanvasElement; if (canvas) { canvas.width = canvas.clientWidth; canvas.height = canvas.clientHeight; requestAnimationFrame(() => { canvas.width = canvas.clientWidth; canvas.height = canvas.clientHeight; }); const id = canvas.getAttribute("data-canvas")!; let cancelled = false; this.unmountCanvas = () => (cancelled = true); ensureCanvasReady(id).then((func) => { if (cancelled) return; this.unmountCanvas = func(canvas, panel) as any; }); if (id === "cotyledon") { filesContainer.classList.add("ctld-sb"); const group = document.querySelector( "[data-group='cotyledon']", )! as HTMLElement; if (group) { group.setAttribute("inert", "true"); group.style.opacity = "0.5"; group.style.pointerEvents = "none"; hasCotyledonSpeedbump = true; } } } const ul = panel.querySelector("ul"); if (!ul) { this.width = 0; this.linkFlags = null; this.basenames = null; this.tooltips = null; return; } this.width = ul.offsetWidth; const links = panel.querySelectorAll("ul > li > a.li"); this.content.setAttribute("data-clover", `${index}`); this.tooltips = []; this.linkFlags = new Array(links.length).fill(0); const basenames = this.basenames = new Array(links.length); for (let i = 0; i < links.length; i++) { const link = links[i] as HTMLAnchorElement; link.setAttribute("data-clover", `${index};${i}`); link.addEventListener("mouseenter", onLinkMouseEnter); basenames[i] = link.classList.contains("readme") ? "readme.txt" : link.getAttribute("href")!.split("/").pop()!; } } update(newActiveFile: string) { console.assert(newActiveFile); const p = this.panel; p.querySelector(".li.active")?.classList.remove("active"); const basenames = this.basenames; if (!basenames) return; const ul = p.querySelector("ul")!; this.width = ul.offsetWidth; console.assert(!newActiveFile.includes("/")); if (hasCotyledonSpeedbump) { newActiveFile = "__"; } const linkIndex = basenames.indexOf(newActiveFile); if (linkIndex === -1) return; const link = p.querySelector(`[data-clover="${this.index};${linkIndex}"]`)!; link.classList.add("active"); const newActiveTooltip = this.tooltips!.findIndex((t) => t.index === linkIndex ); for (let i = 0; i < this.tooltips!.length; i++) { const { tooltip } = this.tooltips![i]; tooltip.classList[i === newActiveTooltip ? "add" : "remove"]("active"); } } hideReadme() { this.panel.classList.remove("last"); const lastHsplit = this.panel.querySelector(".hsplit")!; if (lastHsplit) { lastHsplit.className = "hsplit-hidden"; const previousReadme = this.panel.querySelector( ".content.readme", )! as HTMLElement; console.assert(previousReadme, "No readme found"); previousReadme.style.display = "none"; } } showReadme() { this.panel.classList.add("last"); const hsplit = this.panel.querySelector(".hsplit-hidden")!; if (hsplit) { hsplit.className = "hsplit"; const previousReadme = this.panel.querySelector( ".content.readme", )! as HTMLElement; console.assert(previousReadme, "No readme found"); previousReadme.style.display = "block"; } } destroy() { if (this.unmountCanvas) { this.unmountCanvas(); } this.panel.querySelectorAll("audio,video").forEach((el) => (el as HTMLVideoElement | HTMLAudioElement).pause() ); this.panel.remove(); } } function onContentScrollForTooltip(ev: Event) { const content = ev.target as HTMLElement; const panelIndex = parseInt(content.getAttribute("data-clover")!); const panel = panels[panelIndex]; const scrollTop = content.scrollTop; for (const tooltip of panel.tooltips!) { tooltip.tooltip.style.transform = tooltipTransform(tooltip.top, scrollTop); } } function tooltipTransform(offsetTop: number, scrollTop: number) { return `translateY(${offsetTop - scrollTop}px)`; } let activeTooltip: HTMLElement | null = null; let activeTooltipCancel: (() => void) | null = null; function onLinkMouseEnter(e: MouseEvent) { const link = e.target as HTMLAnchorElement; console.assert(link.classList.contains("li")); const attr = link.getAttribute("data-clover")!; console.assert(attr && attr.match(/^\d+;\d+$/)); const [panelIndex, linkIndex] = attr.split(";").map(Number); const panel = panels[panelIndex]; console.assert(panel, `panel${panelIndex}`); const linkWidths = panel.linkFlags; let filePath: string | null = null; if (linkWidths![linkIndex] == 0) { // filter only links that truncate their text // insane discovery: while this is recommended online, it doesn't // account for the `...` itself, meaning when just the file size // is truncated, a tooltip won't be available. // > if (link.scrollWidth <= link.offsetWidth) continue; const lastChild = link.lastElementChild! as HTMLElement; linkWidths![linkIndex] = lastChild?.offsetLeft !== undefined ? (lastChild.offsetLeft + lastChild.offsetWidth) ^ 0 : 1; const href = (link as HTMLAnchorElement).getAttribute("href") ?? null; filePath = href?.startsWith("/file") ? href.slice(5) || "/" : null; } if (filePath) { prefetchEntry(filePath, true); } if (linkWidths![linkIndex] > panel.width) { if (activeTooltipCancel) { activeTooltipCancel(); activeTooltipCancel = null; } maybeBuildTooltipUi(link, linkIndex, panel); } } function cancelOnMouseLeave() { activeTooltipCancel!(); activeTooltipCancel = null; } function maybeBuildTooltipUi( link: HTMLAnchorElement, linkIndex: number, panel: Panel, ) { if (activeTooltip) { activeTooltip.remove(); buildTooltipUi(link, linkIndex, panel, false); } else { link.addEventListener("mouseleave", cancelOnMouseLeave); const timer = setTimeout(() => { activeTooltipCancel = null; link.removeEventListener("mouseleave", cancelOnMouseLeave); buildTooltipUi(link, linkIndex, panel, true); }, 150); activeTooltipCancel = () => { clearTimeout(timer); link.removeEventListener("mouseleave", cancelOnMouseLeave); }; } } function buildTooltipUi( link: HTMLAnchorElement, linkIndex: number, panel: Panel, animateIn: boolean, ) { const tooltip = activeTooltip = document.createElement("div"); tooltip.classList.add("tooltip"); if (link.classList.contains("active")) { tooltip.classList.add("active"); } if (animateIn) { tooltip.style.animation = "fadeIn .1s ease-out forwards"; } tooltip.innerHTML = link.innerHTML; const top = link.parentElement!.offsetTop; tooltip.style.transform = tooltipTransform(top, panel.content.scrollTop); panel.panel.appendChild(tooltip); panel.tooltips!.push({ tooltip, top, index: linkIndex }); if (panel.tooltips!.length === 1) { panel.content.addEventListener("scroll", onContentScrollForTooltip); } link.addEventListener("mouseleave", (e) => { tooltip.style.animation = "fadeIn .3s .2s ease reverse forwards"; const timer = setTimeout(() => { activeTooltipCancel = null; tooltip.remove(); activeTooltip = null; const tt = panel.tooltips = panel.tooltips!.filter((t) => t.tooltip !== tooltip ); if (tt.length === 0) { panel.content.removeEventListener("scroll", onContentScrollForTooltip); } }, 500); activeTooltipCancel = () => { clearTimeout(timer); }; }, { once: true }); } function addPanel(panel: HTMLElement, activeFile?: string) { const p = new Panel(panel, panels.length); panels.push(p); if (activeFile) { p.update(activeFile); } } async function maybeInterceptClick(event: MouseEvent, element: HTMLElement) { const href = (element as HTMLAnchorElement).href; const url = new URL(href, window.location.origin); if (maybeNavigate(url, true)) { event.preventDefault(); } } type URLLike = Pick; function maybeNavigate(url: URLLike, pushState: boolean) { if (!url.pathname.startsWith("/file")) return false; if (url.origin !== location.origin) return false; if (url.search !== "") return false; navigate(url.pathname, pushState); return true; } async function navigate(pathname: string, pushState: boolean) { const filePath = pathname.slice(5) || "/"; const currentNavigationId = ++navigationId; if (filePath === currentFile) return; const currentSplit = splitSlashes(currentFile); const filePathSplit = splitSlashes(filePath); // Find the first index where the currentSplit and filePathSplit differ, then // add all the paths after that to panelsToFetch let deleteCount = Math.max(currentSplit.length - filePathSplit.length, 0); let appendPanels = []; for (let i = -1; i < filePathSplit.length; i++) { if (currentSplit[i] !== filePathSplit[i]) { deleteCount = currentSplit.length - i; appendPanels = []; for (let j = i; j < filePathSplit.length; j++) { appendPanels.push("/" + filePathSplit.slice(0, j + 1).join("/")); } break; } } // Before fetching, prepare to mark the panels as loading const loadingPanels = new Set(); { let lastPanel = filesContainer.lastElementChild; let toDelete = deleteCount; while (lastPanel && toDelete > 0) { lastPanel.querySelectorAll(".content").forEach((content) => { loadingPanels.add(content as HTMLElement); }); lastPanel = lastPanel.previousElementSibling; toDelete--; } if (deleteCount == 0) { const last = filesContainer.lastElementChild!; console.assert(last, "Last panel is not a panel"); const readme = last.querySelector(".content.readme")!; if (readme) { loadingPanels.add(readme as HTMLElement); } } const folderWithReadme = panels[panels.length - deleteCount - 1]; const readmes = folderWithReadme.panel.querySelectorAll(".readme"); if (readmes.length === 1) { deleteCount += 1; appendPanels.unshift( "/" + currentSplit.slice(0, panels.length - deleteCount).join("/"), ); } } console.assert( deleteCount > 0 || appendPanels.length > 0, "No difference found", ); let timer = loadingPanels.size > 0 ? setTimeout(() => { if (navigationId !== currentNavigationId) { return; // cancelled } document.querySelectorAll(".loading") .forEach((thing) => thing.classList.remove("loading")); for (const panel of loadingPanels) { panel.classList.add("loading"); } timer = null; }, 100) : null; // Fetch the data let appendEntries; try { appendEntries = await Promise.all(appendPanels.map(fetchEntry)); } catch (e) { console.error("error", e); if (navigationId === currentNavigationId) { console.error(e); location.href = "/file" + (filePath.length > 1 ? filePath : ""); } return; // cancelled } if (navigationId !== currentNavigationId) { return; // cancelled } if (timer) clearTimeout(timer); else {for (const panel of loadingPanels) { panel.classList.remove("loading"); }} currentFile = filePath; if (pushState) { history.pushState(null, "", `/file${filePath.length > 1 ? filePath : ""}`); } const startScrollleft = filesContainer.scrollLeft; if (currentSplit[0] !== filePathSplit[0]) { if (currentSplit[0] === "cotyledon") { filesContainer.classList.remove("ctld-et", "ctld-sb"); } if (parseInt(currentSplit[0]) < 2025) { filesContainer.classList.remove("ctld", "ctld-" + currentSplit[0]); } if (parseInt(filePathSplit[0]) < 2025) { filesContainer.classList.add("ctld", "ctld-" + filePathSplit[0]); } } // Make the last panel into a regular panel panels[panels.length - 1].hideReadme(); // Delete the panels that are no longer needed for (let i = 0; i < deleteCount; i++) { const panel = panels.pop(); console.assert(panel, "No panel found"); if (panel) { panel.destroy(); } } // Update the last panel const currentFileSplit = splitSlashes(currentFile); const activeFile = currentFileSplit[panels.length - 1]; panels[panels.length - 1]?.update(activeFile ?? "readme.txt"); // Insert the new panels if (appendEntries.length > 0) { let lastNewPanel: HTMLElement | null = null; for (const entry of appendEntries) { const panel = document.createElement("div"); panel.classList.add("panel"); if (panels.length >= 2) { panel.classList.add("fade-slide-in"); } panel.innerHTML = entry.html; filesContainer.appendChild(panel); lastNewPanel = panel; const current = currentFileSplit[panels.length]; addPanel(panel, current ?? "readme.txt"); if (current) { panels[panels.length - 1].hideReadme(); } } console.assert(lastNewPanel, "No last new panel found"); lastNewPanel!.classList.add("last"); // Automatically play videos const video = lastNewPanel!.querySelector("video") || lastNewPanel!.querySelector("audio"); if (video) { const timer = setTimeout(() => { video.play(); }, 50); video.play().then(() => { clearTimeout(timer); }, () => {}); } } else { // Make the last panel the .last panel const lastPanel = filesContainer.lastElementChild!; console.assert(lastPanel, "No last panel found"); lastPanel.classList.add("last"); panels[panels.length - 1].showReadme(); } updateWidths(); filesContainer.scrollLeft = startScrollleft; requestAnimationFrame(() => { updateWidths(); filesContainer.scrollLeft = startScrollleft; snapToEnd(startScrollleft); }); } function updateWidths() { for (const panel of panels.slice(-2)) { const ul = panel.panel.querySelector("ul")!; if (ul) { panel.width = ul.offsetWidth; } } } function splitSlashes(path: string) { if (path.length <= 1) return []; return path.slice(1).split("/"); } requestAnimationFrame(() => { document.querySelectorAll(".panel").forEach((panel) => addPanel(panel as HTMLElement) ); (document.querySelector(".files")! as HTMLElement).addEventListener( "click", (event, element = event.target as HTMLAnchorElement) => { if ( !(event.button || event.which != 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.defaultPrevented) ) { while (element && element !== document.body) { if ( element.nodeName.toUpperCase() === "A" ) { maybeInterceptClick(event, element); return; } element = (element.assignedSlot ?? element.parentNode) as HTMLAnchorElement; } } }, ); }); window.addEventListener("popstate", (event) => { if (!maybeNavigate(window.location, false)) { location.reload(); } });