const slugify = (text: string) => text.toLowerCase().replace(/[^\w]/g, "-"); const getDepth = (el: Element) => parseInt(el.tagName.replace("H","").replace("h","")); const buildItem = (heading: Element) => { const slug = slugify(heading.textContent ?? ""); const anchor = document.createElement("a"); anchor.setAttribute("href", `#${slug}`); anchor.setAttribute("name", slug); anchor.setAttribute("id", slug); anchor.textContent = "#"; const link = document.createElement("a"); link.setAttribute("href", `#${slug}`); link.textContent = heading.textContent; link.addEventListener("click", e => { const target = document.querySelector(`#${slug}`); if (!target) return; e.preventDefault(); e.stopPropagation(); target.scrollIntoView({behavior: "smooth"}); }); heading.append(anchor); const li = document.createElement("li"); li.append(link); return li; }; /** * Generate a ToC from all heading elements in the main content area. * This should go to full h6 depth and not be too opinionated. It * does assume a "sensible" structure in that you don't go from * h2 > h4 > h1 but rather h2 > h3 > h2 so you change by 1 and end * up at the same level as before. */ export default function generateTOC() { // Get all headings from the page and map them to already built elements const headings = Array.from(document.querySelectorAll("h1, h2, h3, h4, h5, h6")); if (headings.length <= 1) return; // But if there are none, let's do nothing const items = headings.map(h => buildItem(h)); // Setup the ToC list const toc = document.createElement("ul"); toc.id = "toc"; // Get the depth of the first content heading on the page. // This depth will be used as reference for all other headings. // headings[0] === the

from Trilium const firstDepth = getDepth(headings[1]); // Loop over ALL headings including the first for (let h = 0; h < headings.length; h++) { // Get current heading and determine depth const current = headings[h]; const currentDepth = getDepth(current); // If it's the same depth as our first heading, add to ToC if (currentDepth === firstDepth) toc.append(items[h]); // If this is the last element then it will have already // been added as a child or as same depth as first let nextIndex = h + 1; if (nextIndex >= headings.length) continue; // Time to find all children of this heading const children = []; const childDepth = currentDepth + 1; let depthOfNext = getDepth(headings[nextIndex]); while (depthOfNext > currentDepth) { // If it's the expected depth, add as child if (depthOfNext === childDepth) children.push(nextIndex); nextIndex++; // If the next index is valid, grab the depth for next loop // TODO: could this be done cleaner with a for loop? if (nextIndex < headings.length) depthOfNext = getDepth(headings[nextIndex]); else depthOfNext = currentDepth; // If the index was invalid, break loop } // If this heading had children, add them as children if (children.length) { const ul = document.createElement("ul"); for (const c of children) ul.append(items[c]); items[h].append(ul); } } // Setup a moving "active" in the ToC that adjusts with the scroll state const sections = headings.slice(1); const links = toc.querySelectorAll("a"); function changeLinkState() { let index = sections.length; // Work backkwards to find the first matching section while (--index && window.scrollY + 50 < (sections[index] as HTMLElement).offsetTop) {} // eslint-disable-line no-empty // Update the "active" item in ToC links.forEach((link) => link.classList.remove("active")); links[index].classList.add("active"); } // Initial render changeLinkState(); window.addEventListener("scroll", changeLinkState); // Finally, add the ToC to the end of layout. Give the layout a class for adjusting widths. const layout = document.querySelector("#layout"); layout?.classList.add("toc"); layout?.append(toc); }