treehouse/static/js/tree.js

279 lines
9.4 KiB
JavaScript
Raw Normal View History

2023-08-27 15:59:52 +02:00
// This is definitely not a three.js ripoff.
2024-03-03 21:23:37 +01:00
import { addSpell } from "treehouse/spells.js";
2024-03-02 20:53:44 +01:00
import * as ulid from "treehouse/ulid.js";
2023-09-03 11:45:14 +02:00
/* Branch persistence */
const branchStateKey = "treehouse.openBranches";
let branchState = JSON.parse(sessionStorage.getItem(branchStateKey)) || {};
function saveBranchIsOpen(branchID, state) {
branchState[branchID] = state;
sessionStorage.setItem(branchStateKey, JSON.stringify(branchState));
}
function branchIsOpen(branchID) {
return branchState[branchID];
}
2024-03-03 21:23:37 +01:00
export class Branch {
2024-01-18 22:46:57 +01:00
static branchesByNamedID = new Map();
2024-02-21 23:17:19 +01:00
static onAdded = [];
2024-01-18 22:46:57 +01:00
2024-03-03 21:23:37 +01:00
constructor(element) {
this.element = element;
2024-02-07 13:09:44 +01:00
2024-03-03 21:23:37 +01:00
this.isLeaf = element.classList.contains("leaf");
this.details = element.childNodes[0];
2023-08-20 15:05:59 +02:00
this.innerUL = this.details.childNodes[1];
2024-02-07 13:09:44 +01:00
if (this.isLeaf) {
2024-03-03 21:23:37 +01:00
this.contentContainer = element.childNodes[0];
2024-02-07 13:09:44 +01:00
} else {
this.contentContainer = this.details.childNodes[0];
}
this.bulletPoint = this.contentContainer.childNodes[0];
this.branchContent = this.contentContainer.childNodes[1];
this.buttonBar = this.contentContainer.childNodes[2];
2024-03-03 21:23:37 +01:00
let doPersist = !element.hasAttribute("data-th-do-not-persist");
let isOpen = branchIsOpen(element.id);
2023-08-22 22:32:40 +02:00
if (doPersist && isOpen !== undefined) {
this.details.open = isOpen;
}
2024-02-07 13:09:44 +01:00
if (!this.isLeaf) {
2024-03-31 18:52:43 +02:00
this.details.addEventListener("toggle", (_) => {
2024-03-03 21:23:37 +01:00
saveBranchIsOpen(element.id, this.details.open);
2024-02-07 13:09:44 +01:00
});
}
2024-03-31 18:52:43 +02:00
this.namedID = element.id.split(":")[1];
2024-03-03 21:23:37 +01:00
Branch.branchesByNamedID.set(this.namedID, element);
2024-01-18 22:46:57 +01:00
2024-02-21 23:17:19 +01:00
if (ulid.isCanonicalUlid(this.namedID)) {
let timestamp = ulid.getTimestamp(this.namedID);
2024-02-07 13:09:44 +01:00
let date = document.createElement("span");
date.classList.add("branch-date");
date.innerText = timestamp.toLocaleDateString();
this.buttonBar.insertBefore(date, this.buttonBar.firstChild);
}
2024-02-21 23:17:19 +01:00
for (let callback of Branch.onAdded) {
2024-03-03 21:23:37 +01:00
callback(element, this);
2024-02-21 23:17:19 +01:00
}
}
}
2024-03-03 21:23:37 +01:00
addSpell("b", Branch);
2023-09-03 11:45:14 +02:00
/* Linked branches */
class LinkedBranch extends Branch {
static byLink = new Map();
2024-03-03 21:23:37 +01:00
constructor(element) {
super(element);
2024-03-03 21:23:37 +01:00
this.linkedTree = element.getAttribute("data-th-link");
LinkedBranch.byLink.set(this.linkedTree, this);
2023-08-20 15:05:59 +02:00
this.loadingText = document.createElement("p");
{
this.loadingText.className = "link-loading";
let linkedTreeName = document.createElement("code");
linkedTreeName.innerText = this.linkedTree;
this.loadingText.append("Loading ", linkedTreeName, "...");
}
this.innerUL.appendChild(this.loadingText);
// This produces a warning during static generation but we still want to handle that
// correctly, as Branch saves the state in localStorage. Having an expanded-by-default
// linked block can be useful in development.
2023-08-20 15:05:59 +02:00
if (this.details.open) {
this.loadTree("constructor");
2023-08-20 15:05:59 +02:00
}
2024-03-31 18:52:43 +02:00
this.details.addEventListener("toggle", (_) => {
2023-08-20 15:05:59 +02:00
if (this.details.open) {
this.loadTree("toggle");
2023-08-20 15:05:59 +02:00
}
});
}
async loadTreePromise(_initiator) {
try {
2024-03-31 18:52:43 +02:00
let response = await fetch(
`${TREEHOUSE_SITE}/${this.linkedTree}.html`
);
if (response.status == 404) {
throw `Hmm, seems like the tree "${this.linkedTree}" does not exist.`;
}
2023-08-20 15:05:59 +02:00
let text = await response.text();
let parser = new DOMParser();
let linkedDocument = parser.parseFromString(text, "text/html");
let main = linkedDocument.getElementsByTagName("main")[0];
let ul = main.getElementsByTagName("ul")[0];
2024-02-12 19:56:06 +01:00
let styles = main.getElementsByTagName("link");
let scripts = main.getElementsByTagName("script");
this.loadingText.remove();
this.innerUL.innerHTML = ul.innerHTML;
2024-02-14 23:31:39 +01:00
2024-03-03 21:23:37 +01:00
this.element.append(...styles);
2024-02-14 23:31:39 +01:00
for (let script of scripts) {
// No need to await for the import because we don't use the resulting module.
// Just fire and forger 💀
// and let them run in parallel.
let url = URL.createObjectURL(new Blob([script.textContent], { type: "text/javascript" }))
import(url);
2024-02-14 23:31:39 +01:00
}
} catch (error) {
this.loadingText.innerText = error.toString();
2023-08-20 15:05:59 +02:00
}
}
loadTree() {
if (!this.loading) {
this.loading = this.loadTreePromise();
}
return this.loading;
}
2023-08-20 15:05:59 +02:00
}
2024-03-03 21:23:37 +01:00
addSpell("b-linked", LinkedBranch);
2023-08-21 21:12:06 +02:00
2023-09-03 11:45:14 +02:00
/* Fragment navigation */
let rehashing = false;
2024-03-31 18:52:43 +02:00
function rehash() {
// https://www.youtube.com/watch?v=Tv1SYqLllKI
2023-09-03 11:45:14 +02:00
if (!rehashing) {
rehashing = true;
let hash = window.location.hash;
2024-01-18 22:46:57 +01:00
if (hash.length > 0) {
window.location.hash = "";
window.location.hash = hash;
}
2023-09-03 11:45:14 +02:00
rehashing = false;
}
}
2023-08-21 21:12:06 +02:00
function expandDetailsRecursively(element) {
while (element && element.tagName != "MAIN") {
if (element.tagName == "DETAILS") {
element.open = true;
}
element = element.parentElement;
}
}
function navigateToPage(page) {
2024-03-31 18:52:43 +02:00
window.location.pathname = `${page}`;
}
async function navigateToBranch(fragment) {
2023-09-03 11:45:14 +02:00
if (fragment.length == 0) return;
let { navigationMap } = await import("/navmap.js");
let element = document.getElementById(fragment);
if (element !== null) {
2023-08-21 21:12:06 +02:00
// If the element is already loaded on the page, we're good.
expandDetailsRecursively(element);
2023-09-03 11:45:14 +02:00
rehash();
2024-03-31 18:52:43 +02:00
// NOTE(2024-03-31): Only scroll into view in the loaded case.
// This case happens very often with `/b`-navigated branches, and those serve the specific
// page that contains the provided branch.
// Hash-links are not used anymore so upgrading the second case is unnecessary.
// They were a thing before I linked to the treehouse very often so no need to update.
element.scrollIntoView();
} else {
// The element is not loaded, we need to load the tree that has it.
2024-03-31 18:52:43 +02:00
let parts = fragment.split(":");
if (parts.length >= 2) {
let [page, _id] = parts;
let fullPath = navigationMap[page];
if (Array.isArray(fullPath)) {
// TODO: This logic will probably need to be upgraded at some point to support
// navigation maps with roots other than index. Currently though only index is
// generated so that doesn't matter.
let [_root, ...path] = fullPath;
if (path !== undefined) {
2023-09-05 13:51:43 +02:00
let isNotAtIndexHtml =
window.location.pathname != "" &&
window.location.pathname != "/" &&
window.location.pathname != "/index.html";
2023-08-27 18:25:21 +02:00
let lastBranch = null;
for (let linked of path) {
let branch = LinkedBranch.byLink.get(linked);
if (isNotAtIndexHtml && branch === undefined) {
navigateToPage("index");
return;
}
await branch.loadTree("navigateToBranch");
2023-08-27 18:25:21 +02:00
lastBranch = branch;
}
if (lastBranch != null) {
expandDetailsRecursively(lastBranch.details);
}
2023-09-03 11:45:14 +02:00
rehash();
}
} else {
// In case the navigation map does not contain the given page, we can try
// redirecting the user to a concrete page on the site.
navigateToPage(page);
}
}
2023-08-21 21:12:06 +02:00
}
}
2024-01-18 22:46:57 +01:00
function getCurrentlyHighlightedBranch() {
if (window.location.pathname == "/b" && window.location.search.length > 0) {
let shortID = window.location.search.substring(1);
return Branch.branchesByNamedID.get(shortID).id;
} else {
return window.location.hash.substring(1);
}
}
async function navigateToCurrentBranch() {
2024-01-18 22:46:57 +01:00
await navigateToBranch(getCurrentlyHighlightedBranch());
}
// When you click on a link, and the destination is within a <details> that is not expanded,
// expand the <details> recursively.
window.addEventListener("popstate", navigateToCurrentBranch);
addEventListener("DOMContentLoaded", navigateToCurrentBranch);
2023-10-03 15:13:28 +02:00
// When you enter the website through a link someone sent you, it would be nice if the linked branch
// got expanded by default.
async function expandLinkedBranch() {
2024-01-18 22:46:57 +01:00
let currentlyHighlightedBranch = getCurrentlyHighlightedBranch();
if (currentlyHighlightedBranch.length > 0) {
let linkedBranch = document.getElementById(currentlyHighlightedBranch);
2024-03-31 18:52:43 +02:00
if (
linkedBranch.children.length > 0 &&
linkedBranch.children[0].tagName == "DETAILS"
) {
2023-10-03 15:13:28 +02:00
expandDetailsRecursively(linkedBranch.children[0]);
}
}
}
addEventListener("DOMContentLoaded", expandLinkedBranch);
2024-01-18 22:46:57 +01:00
async function highlightCurrentBranch() {
let branch = document.getElementById(getCurrentlyHighlightedBranch());
if (branch != null) {
branch.classList.add("target");
}
}
addEventListener("DOMContentLoaded", highlightCurrentBranch);