1const filesContainer = document.querySelector(".files")!;
2
3// push the scrollbar to the end of the view.
4let distanceFromEnd = 0;
5let fakeScrollToEnd: number | null = null;
6filesContainer.scrollLeft = filesContainer.scrollWidth
7 - filesContainer.clientWidth;
8window.addEventListener("resize", () => {
9 if (fakeScrollToEnd) return;
10 const { scrollWidth, clientWidth } = filesContainer;
11 if (scrollWidth <= clientWidth) {
12 distanceFromEnd = 0;
13 } else {
14 filesContainer.scrollLeft = scrollWidth - clientWidth - distanceFromEnd;
15 }
16}, { passive: false });
17let lastScrollLeft = filesContainer.scrollLeft;
18filesContainer.addEventListener("scroll", (ev) => {
19 const { scrollWidth, scrollLeft, clientWidth } = filesContainer;
20 if (scrollWidth <= clientWidth) {
21 distanceFromEnd = 0;
22 } else {
23 distanceFromEnd = scrollWidth - scrollLeft - clientWidth;
24 if (fakeScrollToEnd && scrollLeft < lastScrollLeft) {
25 cancelAnimationFrame(fakeScrollToEnd);
26 fakeScrollToEnd = null;
27 }
28 }
29 lastScrollLeft = scrollLeft;
30});
31const lerp = (a: number, b: number, t: number) => a + t * (b - a);
32function snapToEnd(initScrollStart: number) {
33 if (fakeScrollToEnd) {
34 cancelAnimationFrame(fakeScrollToEnd);
35 }
36 let lastTime = performance.now();
37 let scrollStart = initScrollStart ?? filesContainer.scrollLeft;
38 if (scrollStart >= filesContainer.scrollWidth) {
39 return;
40 }
41 fakeScrollToEnd = requestAnimationFrame(function tick() {
42 const now = performance.now();
43 const dt = now - lastTime;
44 lastTime = now;
45
46 const f = 1 - (0.98 ** dt);
47 const { scrollWidth, clientWidth } = filesContainer;
48 const target = Math.floor(scrollWidth - clientWidth);
49 scrollStart = lerp(scrollStart, target, f);
50 filesContainer.scrollLeft = scrollStart;
51 if (Math.abs(scrollStart - target) < 0.2) {
52 fakeScrollToEnd = null;
53 } else {
54 fakeScrollToEnd = requestAnimationFrame(tick);
55 }
56 });
57}
58
59interface CacheEntry {
60 html: string;
61 expires: number;
62}
63// It is intentional that the existing page is NOT put into the cache. This is
64// just to avoid the differences between the partials and the full page
65// (subtle differences in activeFilename & isLast)
66let currentFile: string = location.pathname.replace(/^\/file/, "");
67let navigationId = 0;
68const cache = new Map<string, CacheEntry>();
69const prefetching = new Map<string, Promise<CacheEntry>>();
70const fetchLater: string[] = [];
71let hasCotyledonSpeedbump = false;
72
73function prefetchEntry(
74 filePath: string,
75 lazy = false,
76): void | Promise<CacheEntry> {
77 console.assert(filePath[0] === "/", "filePath must start with a /");
78 const existingEntry = cache.get(filePath);
79 if (existingEntry) {
80 if (existingEntry.expires > Date.now()) {
81 return;
82 }
83 cache.delete(filePath);
84 }
85
86 const existingPromise = prefetching.get(filePath);
87 if (existingPromise) return existingPromise;
88
89 // lazy prefetches should be limited
90 if (lazy && prefetching.size > 2) {
91 if (!fetchLater.includes(filePath)) {
92 fetchLater.push(filePath);
93 }
94 return;
95 }
96
97 if (filePath === "/cotyledon") {
98 ensureCanvasReady("cotyledon");
99 }
100
101 const promise = fetch(`/file${filePath}$partial`)
102 .then((resp) => {
103 if (resp.status !== 200) {
104 throw new Error(`Failed to fetch ${filePath}`);
105 }
106 return resp.text();
107 })
108 .then((html) => {
109 const entry: CacheEntry = { html, expires: Date.now() + 1000 * 60 * 20 };
110 cache.set(filePath, entry);
111 prefetching.delete(filePath);
112
113 if (fetchLater.length > 0 && prefetching.size < 2) {
114 const filePath = fetchLater.shift()!;
115 prefetchEntry(filePath, false);
116 }
117
118 return entry;
119 });
120 prefetching.set(filePath, promise);
121
122 return promise;
123}
124
125function fetchEntry(filePath: string): Promise<CacheEntry> {
126 const pf = prefetchEntry(filePath);
127 if (pf) return pf;
128 return Promise.resolve(cache.get(filePath)!);
129}
130
131type CanvasFn = (canvas: HTMLCanvasElement, panel: HTMLElement) => void;
132const fetchCanvas = new Map<string, Promise<CanvasFn>>();
133function ensureCanvasReady(id: string): Promise<CanvasFn> {
134 let func = (globalThis as any)["canvas_" + id];
135 if (func) return Promise.resolve(func);
136 let promise = fetchCanvas.get(id);
137 if (promise) return promise;
138 let resolve: (c: CanvasFn) => void;
139 promise = new Promise<CanvasFn>((r) => resolve = r);
140 fetchCanvas.set(id, promise);
141 const script = document.createElement("script");
142 script.src = `/js/file-viewer/scripts/canvas_${id}.js`;
143 script.async = true;
144 script.onload = () => {
145 func = (globalThis as any)["canvas_" + id];
146 fetchCanvas.delete(id);
147 if (func) {
148 resolve(func);
149 } else {
150 console.error(`Error loading canvas script: ${id}`);
151 }
152 };
153 script.onerror = () => {
154 console.error(`Error loading canvas script: ${id}`);
155 };
156 document.head.appendChild(script);
157 return promise;
158}
159
160interface Tooltip {
161 tooltip: HTMLElement;
162 top: number;
163 index: number;
164}
165
166const panels: Panel[] = [];
167
168class Panel {
169 index: number;
170 panel: HTMLElement;
171 content: HTMLElement;
172 width: number;
173 tooltips: Tooltip[] | null;
174 linkFlags: number[] | null = null;
175 basenames: string[] | null = null;
176 unmountCanvas: (() => void) | null = null;
177
178 constructor(panel: HTMLElement, index: number) {
179 console.assert(panel.classList.contains("panel"));
180 this.panel = panel;
181 this.index = index;
182 this.content = panel.querySelector(".content.primary")!;
183
184 if (index === 0) {
185 this.panel.classList.add("first");
186 }
187
188 const canvas = panel.querySelector(
189 "canvas[data-canvas]",
190 ) as HTMLCanvasElement;
191 if (canvas) {
192 canvas.width = canvas.clientWidth;
193 canvas.height = canvas.clientHeight;
194 requestAnimationFrame(() => {
195 canvas.width = canvas.clientWidth;
196 canvas.height = canvas.clientHeight;
197 });
198 const id = canvas.getAttribute("data-canvas")!;
199 let cancelled = false;
200 this.unmountCanvas = () => (cancelled = true);
201 ensureCanvasReady(id).then((func) => {
202 if (cancelled) return;
203 this.unmountCanvas = func(canvas, panel) as any;
204 });
205 if (id === "cotyledon") {
206 filesContainer.classList.add("ctld-sb");
207 filesContainer.classList.add("ctld");
208 const group = document.querySelector(
209 "[data-group='cotyledon']",
210 )! as HTMLElement;
211 if (group) {
212 group.setAttribute("inert", "true");
213 group.style.opacity = "0.5";
214 group.style.pointerEvents = "none";
215 hasCotyledonSpeedbump = true;
216 }
217 }
218 }
219
220 Array.from(
221 panel.querySelectorAll<HTMLElement>(".clover-video"),
222 (container) => hydrateVideo(container),
223 );
224
225 const ul = panel.querySelector("ul");
226 if (!ul) {
227 this.width = 0;
228 this.linkFlags = null;
229 this.basenames = null;
230 this.tooltips = null;
231 return;
232 }
233 this.width = ul.offsetWidth;
234 const links = panel.querySelectorAll("ul > li > a.li");
235
236 this.content.setAttribute("data-clover", `${index}`);
237
238 this.tooltips = [];
239 this.linkFlags = new Array(links.length).fill(0);
240 const basenames = this.basenames = new Array(links.length);
241 for (let i = 0; i < links.length; i++) {
242 const link = links[i] as HTMLAnchorElement;
243 link.setAttribute("data-clover", `${index};${i}`);
244 link.addEventListener("mouseenter", onLinkMouseEnter);
245 basenames[i] = link.classList.contains("readme")
246 ? "readme.txt"
247 : link.getAttribute("href")!.split("/").pop()!;
248 }
249 }
250
251 update(newActiveFile: string) {
252 console.assert(newActiveFile);
253 const p = this.panel;
254 p.querySelector(".li.active")?.classList.remove("active");
255 const basenames = this.basenames;
256 if (!basenames) return;
257 const ul = p.querySelector("ul")!;
258 this.width = ul.offsetWidth;
259 console.assert(!newActiveFile.includes("/"));
260 if (hasCotyledonSpeedbump) {
261 newActiveFile = "__";
262 }
263
264 const linkIndex = basenames.indexOf(newActiveFile);
265 if (linkIndex === -1) return;
266 const link = p.querySelector(`[data-clover="${this.index};${linkIndex}"]`)!;
267 link.classList.add("active");
268
269 const newActiveTooltip = this.tooltips!.findIndex((t) => t.index === linkIndex);
270 for (let i = 0; i < this.tooltips!.length; i++) {
271 const { tooltip } = this.tooltips![i];
272 tooltip.classList[i === newActiveTooltip ? "add" : "remove"]("active");
273 }
274 }
275
276 hideReadme() {
277 this.panel.classList.remove("last");
278 const lastHsplit = this.panel.querySelector(".hsplit")!;
279 if (lastHsplit) {
280 lastHsplit.className = "hsplit-hidden";
281 const previousReadme = this.panel.querySelector(
282 ".content.readme",
283 )! as HTMLElement;
284 console.assert(previousReadme, "No readme found");
285 previousReadme.style.display = "none";
286 }
287 }
288
289 showReadme() {
290 this.panel.classList.add("last");
291 const hsplit = this.panel.querySelector(".hsplit-hidden")!;
292 if (hsplit) {
293 hsplit.className = "hsplit";
294 const previousReadme = this.panel.querySelector(
295 ".content.readme",
296 )! as HTMLElement;
297 console.assert(previousReadme, "No readme found");
298 previousReadme.style.display = "block";
299 }
300 }
301
302 destroy() {
303 if (this.unmountCanvas) {
304 this.unmountCanvas();
305 }
306 this.panel.querySelectorAll("audio,video").forEach((el) => (el as HTMLVideoElement | HTMLAudioElement).pause());
307 this.panel.remove();
308 }
309}
310
311function onContentScrollForTooltip(ev: Event) {
312 const content = ev.target as HTMLElement;
313 const panelIndex = parseInt(content.getAttribute("data-clover")!);
314 const panel = panels[panelIndex];
315 const scrollTop = content.scrollTop;
316 for (const tooltip of panel.tooltips!) {
317 tooltip.tooltip.style.transform = tooltipTransform(tooltip.top, scrollTop);
318 }
319}
320
321function tooltipTransform(offsetTop: number, scrollTop: number) {
322 return `translateY(${offsetTop - scrollTop}px)`;
323}
324
325let activeTooltip: HTMLElement | null = null;
326let activeTooltipCancel: (() => void) | null = null;
327
328function onLinkMouseEnter(e: MouseEvent) {
329 const link = e.target as HTMLAnchorElement;
330 console.assert(link.classList.contains("li"));
331 const attr = link.getAttribute("data-clover")!;
332 console.assert(attr && attr.match(/^\d+;\d+$/));
333 const [panelIndex, linkIndex] = attr.split(";").map(Number);
334 const panel = panels[panelIndex];
335 console.assert(panel, `panel${panelIndex}`);
336 const linkWidths = panel.linkFlags;
337 let filePath: string | null = null;
338 if (linkWidths![linkIndex] == 0) {
339 // filter only links that truncate their text
340 // insane discovery: while this is recommended online, it doesn't
341 // account for the `...` itself, meaning when just the file size
342 // is truncated, a tooltip won't be available.
343 // > if (link.scrollWidth <= link.offsetWidth) continue;
344 const lastChild = link.lastElementChild! as HTMLElement;
345 linkWidths![linkIndex] = lastChild?.offsetLeft !== undefined
346 ? (lastChild.offsetLeft + lastChild.offsetWidth) ^ 0
347 : 1;
348
349 const href = (link as HTMLAnchorElement).getAttribute("href") ?? null;
350 filePath = href?.startsWith("/file") ? href.slice(5) || "/" : null;
351 }
352 if (filePath) {
353 prefetchEntry(filePath, true);
354 }
355 if (linkWidths![linkIndex] > panel.width) {
356 if (activeTooltipCancel) {
357 activeTooltipCancel();
358 activeTooltipCancel = null;
359 }
360 maybeBuildTooltipUi(link, linkIndex, panel);
361 }
362}
363
364function cancelOnMouseLeave() {
365 activeTooltipCancel!();
366 activeTooltipCancel = null;
367}
368
369function maybeBuildTooltipUi(
370 link: HTMLAnchorElement,
371 linkIndex: number,
372 panel: Panel,
373) {
374 if (activeTooltip) {
375 activeTooltip.remove();
376 buildTooltipUi(link, linkIndex, panel, false);
377 } else {
378 link.addEventListener("mouseleave", cancelOnMouseLeave);
379 const timer = setTimeout(() => {
380 activeTooltipCancel = null;
381 link.removeEventListener("mouseleave", cancelOnMouseLeave);
382 buildTooltipUi(link, linkIndex, panel, true);
383 }, 150);
384 activeTooltipCancel = () => {
385 clearTimeout(timer);
386 link.removeEventListener("mouseleave", cancelOnMouseLeave);
387 };
388 }
389}
390
391function buildTooltipUi(
392 link: HTMLAnchorElement,
393 linkIndex: number,
394 panel: Panel,
395 animateIn: boolean,
396) {
397 const tooltip = activeTooltip = document.createElement("div");
398 tooltip.classList.add("tooltip");
399 if (link.classList.contains("active")) {
400 tooltip.classList.add("active");
401 }
402 if (animateIn) {
403 tooltip.style.animation = "fadeIn .1s ease-out forwards";
404 }
405 tooltip.innerHTML = link.innerHTML;
406 const top = link.parentElement!.offsetTop;
407 tooltip.style.transform = tooltipTransform(top, panel.content.scrollTop);
408 panel.panel.appendChild(tooltip);
409 panel.tooltips!.push({ tooltip, top, index: linkIndex });
410 if (panel.tooltips!.length === 1) {
411 panel.content.addEventListener("scroll", onContentScrollForTooltip);
412 }
413
414 link.addEventListener("mouseleave", (e) => {
415 tooltip.style.animation = "fadeIn .3s .2s ease reverse forwards";
416 const timer = setTimeout(() => {
417 activeTooltipCancel = null;
418 tooltip.remove();
419 activeTooltip = null;
420
421 const tt = panel.tooltips = panel.tooltips!.filter((t) => t.tooltip !== tooltip);
422 if (tt.length === 0) {
423 panel.content.removeEventListener("scroll", onContentScrollForTooltip);
424 }
425 }, 500);
426 activeTooltipCancel = () => {
427 clearTimeout(timer);
428 };
429 }, { once: true });
430}
431
432function addPanel(panel: HTMLElement, activeFile?: string) {
433 const p = new Panel(panel, panels.length);
434 panels.push(p);
435 if (activeFile) {
436 p.update(activeFile);
437 }
438}
439
440async function maybeInterceptClick(event: MouseEvent, element: HTMLElement) {
441 const href = (element as HTMLAnchorElement).href;
442 const url = new URL(href, window.location.origin);
443 if (maybeNavigate(url, true)) {
444 event.preventDefault();
445 }
446}
447
448type URLLike = Pick<URL, "pathname" | "origin" | "search">;
449function maybeNavigate(url: URLLike, pushState: boolean) {
450 if (!url.pathname.startsWith("/file")) return false;
451 if (url.origin !== location.origin) return false;
452 if (url.search !== "") return false;
453 navigate(url.pathname, pushState);
454 return true;
455}
456
457async function navigate(pathname: string, pushState: boolean) {
458 const filePath = pathname.slice(5) || "/";
459
460 const currentNavigationId = ++navigationId;
461
462 if (filePath === currentFile) return;
463
464 const currentSplit = splitSlashes(currentFile);
465 const filePathSplit = splitSlashes(filePath);
466
467 // Find the first index where the currentSplit and filePathSplit differ, then
468 // add all the paths after that to panelsToFetch
469 let deleteCount = Math.max(currentSplit.length - filePathSplit.length, 0);
470 let appendPanels = [];
471 for (let i = -1; i < filePathSplit.length; i++) {
472 if (currentSplit[i] !== filePathSplit[i]) {
473 deleteCount = currentSplit.length - i;
474 appendPanels = [];
475 for (let j = i; j < filePathSplit.length; j++) {
476 appendPanels.push("/" + filePathSplit.slice(0, j + 1).join("/"));
477 }
478 break;
479 }
480 }
481
482 // Before fetching, prepare to mark the panels as loading
483 const loadingPanels = new Set<HTMLElement>();
484 {
485 let lastPanel = filesContainer.lastElementChild;
486 let toDelete = deleteCount;
487 while (lastPanel && toDelete > 0) {
488 lastPanel.querySelectorAll(".content").forEach((content) => {
489 loadingPanels.add(content as HTMLElement);
490 });
491 lastPanel = lastPanel.previousElementSibling;
492 toDelete--;
493 }
494 if (deleteCount == 0) {
495 const last = filesContainer.lastElementChild!;
496 console.assert(last, "Last panel is not a panel");
497
498 const readme = last.querySelector(".content.readme")!;
499 if (readme) {
500 loadingPanels.add(readme as HTMLElement);
501 }
502 }
503 const folderWithReadme = panels[panels.length - deleteCount - 1];
504 const readmes = folderWithReadme.panel.querySelectorAll(".readme");
505 if (readmes.length === 1) {
506 deleteCount += 1;
507 appendPanels.unshift(
508 "/" + currentSplit.slice(0, panels.length - deleteCount).join("/"),
509 );
510 }
511 }
512
513 console.assert(
514 deleteCount > 0 || appendPanels.length > 0,
515 "No difference found",
516 );
517 let timer = loadingPanels.size > 0
518 ? setTimeout(() => {
519 if (navigationId !== currentNavigationId) {
520 return; // cancelled
521 }
522 document.querySelectorAll(".loading")
523 .forEach((thing) => thing.classList.remove("loading"));
524 for (const panel of loadingPanels) {
525 panel.classList.add("loading");
526 }
527 timer = null;
528 }, 100)
529 : null;
530
531 // Fetch the data
532 let appendEntries;
533 try {
534 appendEntries = await Promise.all(appendPanels.map(fetchEntry));
535 } catch (e) {
536 console.error("error", e);
537 if (navigationId === currentNavigationId) {
538 console.error(e);
539 location.href = "/file" + (filePath.length > 1 ? filePath : "");
540 }
541 return; // cancelled
542 }
543 if (navigationId !== currentNavigationId) {
544 return; // cancelled
545 }
546 if (timer) clearTimeout(timer);
547 else {for (const panel of loadingPanels) {
548 panel.classList.remove("loading");
549 }}
550 currentFile = filePath;
551
552 if (pushState) {
553 history.pushState(null, "", `/file${filePath.length > 1 ? filePath : ""}`);
554 }
555
556 const startScrollleft = filesContainer.scrollLeft;
557
558 if (currentSplit[0] !== filePathSplit[0]) {
559 if (currentSplit[0] === "cotyledon") {
560 filesContainer.classList.remove("ctld", "ctld-et", "ctld-sb");
561 }
562 if (parseInt(currentSplit[0]) > 2025) {
563 filesContainer.classList.remove("ctld", "ctld-" + currentSplit[0]);
564 }
565 if (parseInt(filePathSplit[0]) < 2025) {
566 filesContainer.classList.add("ctld", "ctld-" + filePathSplit[0]);
567 }
568 }
569
570 // Make the last panel into a regular panel
571 panels[panels.length - 1].hideReadme();
572
573 // Delete the panels that are no longer needed
574 for (let i = 0; i < deleteCount; i++) {
575 const panel = panels.pop();
576 console.assert(panel, "No panel found");
577 if (panel) {
578 panel.destroy();
579 }
580 }
581
582 // Update the last panel
583 const currentFileSplit = splitSlashes(currentFile);
584 const activeFile = currentFileSplit[panels.length - 1];
585 panels[panels.length - 1]?.update(activeFile ?? "readme.txt");
586
587 // Insert the new panels
588 if (appendEntries.length > 0) {
589 let lastNewPanel: HTMLElement | null = null;
590 for (const entry of appendEntries) {
591 const panel = document.createElement("div");
592 panel.classList.add("panel");
593 if (panels.length >= 2) {
594 panel.classList.add("fade-slide-in");
595 }
596 panel.innerHTML = entry.html;
597 filesContainer.appendChild(panel);
598 lastNewPanel = panel;
599 const current = currentFileSplit[panels.length];
600 addPanel(panel, current ?? "readme.txt");
601 if (current) {
602 panels[panels.length - 1].hideReadme();
603 }
604 }
605 console.assert(lastNewPanel, "No last new panel found");
606 lastNewPanel!.classList.add("last");
607
608 // Automatically play videos
609 const video = lastNewPanel!.querySelector("video")
610 || lastNewPanel!.querySelector("audio");
611 if (video) {
612 const timer = setTimeout(() => {
613 video.play();
614 }, 50);
615 video.play().then(() => {
616 clearTimeout(timer);
617 }, () => {});
618 }
619 } else {
620 // Make the last panel the .last panel
621 const lastPanel = filesContainer.lastElementChild!;
622 console.assert(lastPanel, "No last panel found");
623 lastPanel.classList.add("last");
624 panels[panels.length - 1].showReadme();
625 }
626
627 updateWidths();
628 filesContainer.scrollLeft = startScrollleft;
629 requestAnimationFrame(() => {
630 updateWidths();
631 filesContainer.scrollLeft = startScrollleft;
632 snapToEnd(startScrollleft);
633 });
634}
635
636function updateWidths() {
637 for (const panel of panels.slice(-2)) {
638 const ul = panel.panel.querySelector("ul")!;
639 if (ul) {
640 panel.width = ul.offsetWidth;
641 }
642 }
643}
644
645function splitSlashes(path: string) {
646 if (path.length <= 1) return [];
647 return path.slice(1).split("/");
648}
649
650requestAnimationFrame(() => {
651 document.querySelectorAll(".panel").forEach((panel) => addPanel(panel as HTMLElement));
652 (document.querySelector(".files")! as HTMLElement).addEventListener(
653 "click",
654 (event, element = event.target as HTMLAnchorElement) => {
655 if (
656 !(event.button
657 || event.which != 1
658 || event.metaKey
659 || event.ctrlKey
660 || event.shiftKey
661 || event.altKey
662 || event.defaultPrevented)
663 ) {
664 while (element && element !== document.body) {
665 if (
666 element.nodeName.toUpperCase() === "A"
667 ) {
668 maybeInterceptClick(event, element);
669 return;
670 }
671 element = (element.assignedSlot ?? element.parentNode) as HTMLAnchorElement;
672 }
673 }
674 },
675 );
676});
677
678window.addEventListener("popstate", (event) => {
679 if (!maybeNavigate(window.location, false)) {
680 location.reload();
681 }
682});