mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-08-10 18:39:22 +08:00
Generate ToC in templates
This commit is contained in:
parent
074ac0b725
commit
e7a3f6d17e
@ -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",
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
83
src/scripts/test.ts
Normal file
File diff suppressed because one or more lines are too long
@ -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);
|
||||
}
|
@ -4,7 +4,7 @@ a[href^="https://"] {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#main a[href^="https://"] {
|
||||
#content a[href^="https://"] {
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
19
src/templates/toc_item.ejs
Normal file
19
src/templates/toc_item.ejs
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user