685 lines
20 KiB
TypeScript
685 lines
20 KiB
TypeScript
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<string, CacheEntry>();
|
|
const prefetching = new Map<string, Promise<CacheEntry>>();
|
|
const fetchLater: string[] = [];
|
|
let hasCotyledonSpeedbump = false;
|
|
|
|
function prefetchEntry(
|
|
filePath: string,
|
|
lazy = false,
|
|
): void | Promise<CacheEntry> {
|
|
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<CacheEntry> {
|
|
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<string, Promise<CanvasFn>>();
|
|
function ensureCanvasReady(id: string): Promise<CanvasFn> {
|
|
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<CanvasFn>((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<URL, "pathname" | "origin" | "search">;
|
|
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<HTMLElement>();
|
|
{
|
|
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();
|
|
}
|
|
});
|