mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 18:36:30 +01:00 
			
		
		
		
	Generate ToC in templates
This commit is contained in:
		| @@ -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)); | ||||
|     const toc = document.getElementById("toc"); | ||||
|     if (!toc) return; | ||||
|  | ||||
|     // Setup the ToC list | ||||
|     const toc = document.createElement("ul"); | ||||
|     toc.id = "toc"; | ||||
|     // Get all relevant elements | ||||
|     const sections = document.getElementById("content")!.querySelectorAll("h2, h3, h4, h5, h6"); | ||||
|     const links = toc.querySelectorAll("a"); | ||||
|  | ||||
|     // 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]); | ||||
|     // 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(); | ||||
|              | ||||
|     // 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); | ||||
|         } | ||||
|             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> | ||||
		Reference in New Issue
	
	Block a user