Generate ToC in templates

This commit is contained in:
Zack Rauen 2023-09-29 01:43:39 -04:00
parent 074ac0b725
commit e7a3f6d17e
9 changed files with 200 additions and 129 deletions

View File

@ -10,7 +10,7 @@
"build-styles": "esrun scripts/build.ts -- --module=styles",
"templates": "esrun scripts/build.ts -- --only-templates",
"dist": "esrun scripts/build.ts -- --minify",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "esrun src/scripts/test.ts"
},
"author": "",
"license": "ISC",

View File

@ -20,13 +20,14 @@ if (process.env.TRILIUM_ETAPI_TOKEN) tepi.token(process.env.TRILIUM_ETAPI_TOKEN)
const templateMap: Record<string, string> = {
"src/templates/page.ejs": process.env.PAGE_TEMPLATE_ID!,
"src/templates/tree_item.ejs": process.env.ITEM_TEMPLATE_ID!,
page: process.env.PAGE_TEMPLATE_ID!,
tree_item: process.env.ITEM_TEMPLATE_ID!,
toc_item: process.env.TOC_TEMPLATE_ID!,
};
async function sendTemplates() {
for (const template in templateMap) {
const templatePath = path.join(rootDir, template);
const templatePath = path.join(rootDir, "src", "templates", `${template}.ejs`);
const contents = fs.readFileSync(templatePath).toString();
await tepi.putNoteContentById(templateMap[template], contents);
}

View File

@ -57,8 +57,6 @@ function $try<T extends (...a: unknown[]) => unknown>(func: T, ...args: Paramete
// $try(buildBreadcrumbs);
// $try(buildSidenav);
// TODO: Determine the difficulty of adding this in
// trilium directly using JSDOM.
$try(setupToC);
// Finally, other features

View File

@ -1,99 +1,34 @@
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.className = "toc-anchor";
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.
* The ToC is now generated in the page template so
* it even exists for users without client-side js
* and that means it loads with the page so it avoids
* all potential reshuffling or layout recalculations.
*
* So, all this function needs to do is make the links
* perform smooth animation, and adjust the "active"
* entry as the user scrolls.
*/
export default function setupToC() {
// 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";
const toc = document.getElementById("toc");
if (!toc) return;
// 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 <h1> from Trilium
const firstDepth = getDepth(headings[1]);
// Get all relevant elements
const sections = document.getElementById("content")!.querySelectorAll("h2, h3, h4, h5, h6");
const links = toc.querySelectorAll("a");
// 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 smooth scroll on click
for (const link of links) {
link.addEventListener("click", e => {
const target = document.querySelector(link.getAttribute("href")!);
if (!target) return;
e.preventDefault();
e.stopPropagation();
target.scrollIntoView({behavior: "smooth"});
});
}
// 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;
@ -108,19 +43,4 @@ export default function setupToC() {
// Initial render
changeLinkState();
window.addEventListener("scroll", changeLinkState);
// Create the toc wrapper
const pane = document.createElement("div");
pane.id = "toc-pane";
// Create the header
const header = document.createElement("h3");
header.textContent = "On This Page";
pane.append(header);
pane.append(toc);
// Finally, add the ToC to the end of layout. Give the layout a class for adjusting widths.
const layout = document.querySelector("#right-pane");
layout?.classList.add("toc");
layout?.append(pane);
}

83
src/scripts/test.ts Normal file

File diff suppressed because one or more lines are too long

View File

@ -14,6 +14,10 @@
border-top: 1px solid var(--background-highlight);
}
.no-content + #childLinks {
border: 0;
}
#childLinks ul {
padding: 0;
gap: 10px;
@ -22,32 +26,31 @@
}
#childLinks li {
background: var(--background-highlight);
padding: 2px 12px;
border-radius: 12px;
}
.no-content + #childLinks {
border: 0;
}
#childLinks.grid li {
padding: 0;
background: var(--background-highlight);
border-radius: 12px;
}
#childLinks.grid li a {
padding: 50px;
border-radius: 12px;
#childLinks li a {
padding: 2px 12px;
background: var(--background-highlight);
color: var(--text-primary);
border-radius: 12px;
transform: translateY(0);
transition: transform 200ms ease, background-color 200ms ease, color 200ms ease;
}
#childLinks.grid li a:hover {
#childLinks li a:hover {
background: var(--background-active);
color: var(--background-secondary);
text-decoration: none;
transform: translateY(-2px);
}
#childLinks.grid li a {
padding: 50px;
color: var(--text-primary);
}
#childLinks.grid li a:hover {
transform: translateY(-5px);
}

View File

@ -4,7 +4,7 @@ a[href^="https://"] {
gap: 6px;
}
#main a[href^="https://"] {
#content a[href^="https://"] {
padding-right: 6px;
}

View File

@ -87,6 +87,13 @@ const customServerYml = `- url: "{protocol}://{domain}:{port}/etapi"
<%
const currentTheme = note.getLabel("shareTheme") === "light" ? "light" : "dark";
const themeClass = currentTheme === "light" ? " theme-light" : " theme-dark";
const headingRe = /(<h[1-6]>)(.+?)(<\/h[1-6]>)/g;
const headingMatches = [...content.matchAll(headingRe)];
const slugify = (text) => text.toLowerCase().replace(/[^\w]/g, "-");
content = content.replaceAll(headingRe, (...match) => {
match[0] = match[0].replace(match[3], `<a id="${slugify(match[2])}" class="toc-anchor" name="${slugify(match[2])}" href="#${slugify(match[2])}">#</a>${match[3]}`);
return match[0];
});
%>
<body data-note-id="<%= note.noteId %>" class="type-<%= note.type %><%= themeClass %>">
<div id="mobile-header">
@ -151,16 +158,56 @@ const themeClass = currentTheme === "light" ? " theme-light" : " theme-dark";
<%
const action = note.type === "book" ? "getChildNotes" : "getVisibleChildNotes";
for (const childNote of note[action]()) {
const isExternalLink = childNote.hasLabel("shareExternal");
const linkHref = isExternalLink ? childNote.getLabelValue("shareExternal") : `./${childNote.shareId}`;
const target = isExternalLink ? ` target="_blank" rel="noopener noreferrer"` : "";
%>
<li>
<a href="<%= childNote.shareId %>"
class="type-<%= childNote.type %>"><%= childNote.title %></a>
<a class="type-<%= childNote.type %>" href="<%= linkHref %>"<%= target %>><%= childNote.title %></a>
</li>
<% } %>
</ul>
</nav>
<% } else %>
<% } %>
</div>
<%
if (headingMatches.length > 1) {
const level = (m) => parseInt(m[1].replace(/[<h>]+/g, ""));
const toc = [
{
level: level(headingMatches[0]),
name: headingMatches[0][2],
children: []
}
];
const last = (arr = toc) => arr[arr.length - 1];
const makeEntry = (m) => ({level: level(m), name: m[2], children: []});
const getLevelArr = (lvl, arr = toc) => {
if (arr[0].level === lvl) return arr;
const top = last(arr);
return top.children.length ? getLevelArr(lvl, top.children) : top.children;
};
for (let m = 1; m < headingMatches.length; m++) {
const target = getLevelArr(level(headingMatches[m]));
target.push(makeEntry(headingMatches[m]));
}
%>
<div id="toc-pane">
<h3>On This Page</h3>
<ul id="toc">
<%
let active = true;
for (const entry of toc) {
%>
<%- include('toc_item', {entry, active}) %>
<% active = false %>
<% } %>
</ul>
</div>
<% } %>
</div>
</div>
</body>

View File

@ -0,0 +1,19 @@
<%
const slugify = (text) => text.toLowerCase().replace(/[^\w]/g, "-");
const slug = slugify(entry.name);
%>
<li>
<a href="#<%= slug %>"<% if (active) { %> class="active"<% } %>>
<%= entry.name %>
</a>
<% if (entry.children.length) { %>
<ul>
<% for (const subentry of entry.children) { %>
<%- include('toc_item', {entry: subentry, active: false}) %>
<% } %>
</ul>
<% } %>
</li>