sitegen/src/file-viewer/views/clofi.client.ts

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();
}
});