mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-08-18 00:02:28 +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",
|
"build-styles": "esrun scripts/build.ts -- --module=styles",
|
||||||
"templates": "esrun scripts/build.ts -- --only-templates",
|
"templates": "esrun scripts/build.ts -- --only-templates",
|
||||||
"dist": "esrun scripts/build.ts -- --minify",
|
"dist": "esrun scripts/build.ts -- --minify",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "esrun src/scripts/test.ts"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
@ -20,13 +20,14 @@ if (process.env.TRILIUM_ETAPI_TOKEN) tepi.token(process.env.TRILIUM_ETAPI_TOKEN)
|
|||||||
|
|
||||||
|
|
||||||
const templateMap: Record<string, string> = {
|
const templateMap: Record<string, string> = {
|
||||||
"src/templates/page.ejs": process.env.PAGE_TEMPLATE_ID!,
|
page: process.env.PAGE_TEMPLATE_ID!,
|
||||||
"src/templates/tree_item.ejs": process.env.ITEM_TEMPLATE_ID!,
|
tree_item: process.env.ITEM_TEMPLATE_ID!,
|
||||||
|
toc_item: process.env.TOC_TEMPLATE_ID!,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function sendTemplates() {
|
async function sendTemplates() {
|
||||||
for (const template in templateMap) {
|
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();
|
const contents = fs.readFileSync(templatePath).toString();
|
||||||
await tepi.putNoteContentById(templateMap[template], contents);
|
await tepi.putNoteContentById(templateMap[template], contents);
|
||||||
}
|
}
|
||||||
|
@ -57,8 +57,6 @@ function $try<T extends (...a: unknown[]) => unknown>(func: T, ...args: Paramete
|
|||||||
// $try(buildBreadcrumbs);
|
// $try(buildBreadcrumbs);
|
||||||
// $try(buildSidenav);
|
// $try(buildSidenav);
|
||||||
|
|
||||||
// TODO: Determine the difficulty of adding this in
|
|
||||||
// trilium directly using JSDOM.
|
|
||||||
$try(setupToC);
|
$try(setupToC);
|
||||||
|
|
||||||
// Finally, other features
|
// Finally, other features
|
||||||
|
@ -1,99 +1,34 @@
|
|||||||
const slugify = (text: string) => text.toLowerCase().replace(/[^\w]/g, "-");
|
/**
|
||||||
|
* 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() {
|
||||||
|
const toc = document.getElementById("toc");
|
||||||
|
if (!toc) return;
|
||||||
|
|
||||||
const getDepth = (el: Element) => parseInt(el.tagName.replace("H","").replace("h",""));
|
// Get all relevant elements
|
||||||
|
const sections = document.getElementById("content")!.querySelectorAll("h2, h3, h4, h5, h6");
|
||||||
|
const links = toc.querySelectorAll("a");
|
||||||
|
|
||||||
const buildItem = (heading: Element) => {
|
// Setup smooth scroll on click
|
||||||
const slug = slugify(heading.textContent ?? "");
|
for (const link of links) {
|
||||||
|
|
||||||
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 => {
|
link.addEventListener("click", e => {
|
||||||
const target = document.querySelector(`#${slug}`);
|
const target = document.querySelector(link.getAttribute("href")!);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
target.scrollIntoView({behavior: "smooth"});
|
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 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";
|
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// 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
|
// 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() {
|
function changeLinkState() {
|
||||||
let index = sections.length;
|
let index = sections.length;
|
||||||
|
|
||||||
@ -108,19 +43,4 @@ export default function setupToC() {
|
|||||||
// Initial render
|
// Initial render
|
||||||
changeLinkState();
|
changeLinkState();
|
||||||
window.addEventListener("scroll", 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);
|
border-top: 1px solid var(--background-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-content + #childLinks {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
#childLinks ul {
|
#childLinks ul {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@ -22,32 +26,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#childLinks li {
|
#childLinks li {
|
||||||
background: var(--background-highlight);
|
|
||||||
padding: 2px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.no-content + #childLinks {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#childLinks.grid li {
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
background: var(--background-highlight);
|
||||||
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#childLinks.grid li a {
|
#childLinks li a {
|
||||||
padding: 50px;
|
padding: 2px 12px;
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--background-highlight);
|
background: var(--background-highlight);
|
||||||
color: var(--text-primary);
|
border-radius: 12px;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
transition: transform 200ms ease, background-color 200ms ease, color 200ms ease;
|
transition: transform 200ms ease, background-color 200ms ease, color 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#childLinks.grid li a:hover {
|
#childLinks li a:hover {
|
||||||
background: var(--background-active);
|
background: var(--background-active);
|
||||||
color: var(--background-secondary);
|
color: var(--background-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#childLinks.grid li a {
|
||||||
|
padding: 50px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#childLinks.grid li a:hover {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-5px);
|
||||||
}
|
}
|
@ -4,7 +4,7 @@ a[href^="https://"] {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#main a[href^="https://"] {
|
#content a[href^="https://"] {
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,6 +87,13 @@ const customServerYml = `- url: "{protocol}://{domain}:{port}/etapi"
|
|||||||
<%
|
<%
|
||||||
const currentTheme = note.getLabel("shareTheme") === "light" ? "light" : "dark";
|
const currentTheme = note.getLabel("shareTheme") === "light" ? "light" : "dark";
|
||||||
const themeClass = currentTheme === "light" ? " theme-light" : " theme-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 %>">
|
<body data-note-id="<%= note.noteId %>" class="type-<%= note.type %><%= themeClass %>">
|
||||||
<div id="mobile-header">
|
<div id="mobile-header">
|
||||||
@ -151,16 +158,56 @@ const themeClass = currentTheme === "light" ? " theme-light" : " theme-dark";
|
|||||||
<%
|
<%
|
||||||
const action = note.type === "book" ? "getChildNotes" : "getVisibleChildNotes";
|
const action = note.type === "book" ? "getChildNotes" : "getVisibleChildNotes";
|
||||||
for (const childNote of note[action]()) {
|
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>
|
<li>
|
||||||
<a href="<%= childNote.shareId %>"
|
<a class="type-<%= childNote.type %>" href="<%= linkHref %>"<%= target %>><%= childNote.title %></a>
|
||||||
class="type-<%= childNote.type %>"><%= childNote.title %></a>
|
|
||||||
</li>
|
</li>
|
||||||
<% } %>
|
<% } %>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<% } else %>
|
<% } %>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</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