diff --git a/package.json b/package.json index 75d438fc0..1b94b9e61 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build.ts b/scripts/build.ts index a4f3c39b0..ac17d931a 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -20,13 +20,14 @@ if (process.env.TRILIUM_ETAPI_TOKEN) tepi.token(process.env.TRILIUM_ETAPI_TOKEN) const templateMap: Record = { - "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); } diff --git a/src/scripts/index.ts b/src/scripts/index.ts index 6164f04fc..b05c31441 100644 --- a/src/scripts/index.ts +++ b/src/scripts/index.ts @@ -57,8 +57,6 @@ function $try 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 diff --git a/src/scripts/modules/toc.ts b/src/scripts/modules/toc.ts index 5bd8b3dc2..f38b1de1d 100644 --- a/src/scripts/modules/toc.ts +++ b/src/scripts/modules/toc.ts @@ -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

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); } \ No newline at end of file diff --git a/src/scripts/test.ts b/src/scripts/test.ts new file mode 100644 index 000000000..01a0c62a4 --- /dev/null +++ b/src/scripts/test.ts @@ -0,0 +1,83 @@ +/* eslint-disable no-console */ + +/** + * This script was used for testing ToC generation in the page template... + * TODO: find a better way to integrate ts/js into the templates so I'm + * not debugging on the fly constantly. + */ +// const data = `

Trilium  really does rock! Don't believe me? Well this entire website was made using the shared notes feature inside Trilium with a little bit of extra CSS and JS also contained in Trilium.

It turns Trilium into an insanely powerful WYSIWYG website creator. But that's just a side feature of Trilium, it's so much more powerful with endless possibilities.

Why It Rocks

If somehow you aren't already convinced, take a look below for even more reasons why Trilium rocks!

Built-in Features

This section is shamelessly borrowed from Trilium's README.

Community Addons

Nriver maintains an awesome list of addons for Trilium made by the community. Check out the official list on GitHub. We do mirror the list here on the Showcase page if you just want a quick look.

Custom Scripts

In addition to using community made scripts, widgets, themes, and everything in between, Trilium leaves things open-ended for you the end-user. You can script as much or as little as you like inside Trilium. You can automate all kinds of workflows, do data analysis, or even simple things like set a keybind to open a specific note. The world is your oyster as they say, and Trilium is your world. Pretend that made sense.

 

About This Site

This website is not at all affiliated with Trilium Notes or its creator(s). The site is broken up into a few main sections that you can see in the navigation bar at the top of the page. At a high level, there's two sections targeting end-users, two sections targeting developers, and one meant for everyone.

Status

This site is still a work-in-progress! Writing documentation isn't the most fun thing in the world so this will just be something I work on when I have free time. You'll usually find me working on one of my Trilium-related addons, Trilium itself, or my other open-source project: BetterDiscord.

Goals

Rather than saying some specific goals of what this site strives to be, I'll say what it strives not to be; This site is not meant to be a complete recreation of the Wiki with every detail and page included. It is meant to be a (mostly) one-stop shop for users and developers alike looking to supplement their knowledge. It may at some point expand and include everything from the wiki because users tend to prefer a fancier UI like this, but it is not the end-goal.

Contributing

Since this entire site is just a share from my personal Trilium instance, there is no easy way to contribute new pages or fixes for typos. At some point I will create a GitHub repository for this site's supplementary CSS and JS, and that repository can also act as a home for issues and discussion. But who knows, maybe within that time frame I'll think of some clever way to introduce contributions.

 

`; +const data = `

Frontend API

The frontend api supports two styles, regular scripts that are run with the current app and note context, and widgets that export an object to Trilium to be used in the UI. In both cases, the frontend api of Trilium is available to scripts running in the frontend context as global variable api. The members and methods of the api can be seen on the FrontendScriptApi page.

Scripts

Scripts don't have any special requirements. They can be run at will using the execute button in the UI or they can be configured to run at certain times using Attributes on the note containing the script.

Global Events

This attribute is called #run and it can have any of the following values:

Entity Events

These events are triggered by certain relations to other notes. Meaning that the script is triggered only if the note has this script attached to it through relations (or it can inherit it).

Widgets

Conversely to scripts, widgets do have some specific requirements in order to work. A widget must:

parentWidget

Tutorial

For more information on building widgets, take a look at Widget Basics.

`; +const headingRe = /()(.+?)(<\/h[1-6]>)/g; + + +// const slugify = (text: string) => text.toLowerCase().replace(/[^\w]/g, "-"); +// const modified = data2.replaceAll(headingRe, (...match: RegExpMatchArray) => { +// match[0] = match[0].replace(match[3], `#${match[3]}`); +// return match[0]; +// }); + +// console.log(modified); + + +const headingMatches = [...data.matchAll(headingRe)]; + +interface ToCEntry { + level: number; + name: string; + children: ToCEntry[]; +} + +const level = (m: RegExpMatchArray) => parseInt(m[1].replace(/[]+/g, "")); + +const toc: ToCEntry[] = [ + { + level: level(headingMatches[0]), + name: headingMatches[0][2], + children: [] + } +]; +const last = (arr = toc) => arr[arr.length - 1]; +const makeEntry = (m: RegExpMatchArray): ToCEntry => ({level: level(m), name: m[2], children: []}); + +const getLevelArr = (lvl: number, arr = toc): ToCEntry[] => { + 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])); +} + +console.log(JSON.stringify(toc, null, 4)); + +// const end = (arr = toc): ToCEntry => { +// const top = last(arr); +// return top.children.length ? end(top.children) : top; +// }; +// console.log(end()); + +// const previousEntry = last(); +// if (previousEntry.level === cLvl) { +// toc.push(makeEntry(current)); +// } +// else if (previousEntry.level === cLvl - 1) { +// previousEntry.children.push(makeEntry(current)); +// } +// else if (previousEntry.level < cLvl) { +// const target = findParentEntry(previous[2]) ?? end(); +// // console.log(previous[2], target, current[2]); +// const plvl = level(previous); +// if (plvl === cLvl) { +// target.children.push(makeEntry(current)); +// } +// else if (plvl === cLvl - 1) { +// const subitem = target.children.find(e => e.name === previous[2])!; +// subitem.children.push(makeEntry(current)); +// } +// } +// else if (previousEntry.level > cLvl) { +// toc.push(makeEntry(current)); +// } \ No newline at end of file diff --git a/src/styles/childlinks.css b/src/styles/childlinks.css index 094f48efb..7bb2b6a3e 100644 --- a/src/styles/childlinks.css +++ b/src/styles/childlinks.css @@ -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); } \ No newline at end of file diff --git a/src/styles/externallinks.css b/src/styles/externallinks.css index 9beffff9a..f8e5b6eda 100644 --- a/src/styles/externallinks.css +++ b/src/styles/externallinks.css @@ -4,7 +4,7 @@ a[href^="https://"] { gap: 6px; } -#main a[href^="https://"] { +#content a[href^="https://"] { padding-right: 6px; } diff --git a/src/templates/page.ejs b/src/templates/page.ejs index b702131ac..480f65c3c 100644 --- a/src/templates/page.ejs +++ b/src/templates/page.ejs @@ -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]>)/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], `#${match[3]}`); + return match[0]; +}); %>
@@ -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"` : ""; %>
  • - <%= childNote.title %> + ><%= childNote.title %>
  • <% } %> - <% } else %> + <% } %>
    + <% + if (headingMatches.length > 1) { + const level = (m) => parseInt(m[1].replace(/[]+/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])); + } + %> +
    +

    On This Page

    +
      + <% + let active = true; + for (const entry of toc) { + %> + <%- include('toc_item', {entry, active}) %> + <% active = false %> + <% } %> +
    +
    + <% } %> diff --git a/src/templates/toc_item.ejs b/src/templates/toc_item.ejs new file mode 100644 index 000000000..d613e52f9 --- /dev/null +++ b/src/templates/toc_item.ejs @@ -0,0 +1,19 @@ +<% +const slugify = (text) => text.toLowerCase().replace(/[^\w]/g, "-"); +const slug = slugify(entry.name); +%> + + +
  • + class="active"<% } %>> + <%= entry.name %> + + + <% if (entry.children.length) { %> +
      + <% for (const subentry of entry.children) { %> + <%- include('toc_item', {entry: subentry, active: false}) %> + <% } %> +
    + <% } %> +