/** * Table of contents widget * (c) Antonio Tejada 2022 * * By design, there's no support for nonsensical or malformed constructs: * - headings inside elements (e.g. Trilium allows headings inside tables, but * not inside lists) * - nested headings when using raw HTML

* - malformed headings when using raw HTML

* - etc. * * In those cases, the generated TOC may be incorrect, or the navigation may lead * to the wrong heading (although what "right" means in those cases is not * clear), but it won't crash. */ import { t } from "../services/i18n.js"; import attributeService from "../services/attributes.js"; import RightPanelWidget from "./right_panel_widget.js"; import options from "../services/options.js"; import OnClickButtonWidget from "./buttons/onclick_button.js"; import appContext, { type EventData } from "../components/app_context.js"; import libraryLoader from "../services/library_loader.js"; import type FNote from "../entities/fnote.js"; const TPL = `
`; export default class TocWidget extends RightPanelWidget { private $toc!: JQuery; get widgetTitle() { return t("toc.table_of_contents"); } get widgetButtons() { return [ new OnClickButtonWidget() .icon("bx-cog") .title(t("toc.options")) .titlePlacement("left") .onClick(() => appContext.tabManager.openContextWithNote("_optionsTextNotes", { activate: true })) .class("icon-action"), new OnClickButtonWidget() .icon("bx-x") .titlePlacement("left") .onClick((widget) => widget.triggerCommand("closeToc")) .class("icon-action") ]; } isEnabled() { if (!super.isEnabled() || !this.note) { return false; } const isHelpNote = (this.note.type === "doc" && this.note.noteId.startsWith("_help")); const isTextNote = (this.note.type === "text"); const isNoteSupported = isTextNote || isHelpNote; return isNoteSupported && !this.noteContext?.viewScope?.tocTemporarilyHidden && this.noteContext?.viewScope?.viewMode === "default"; } async doRenderBody() { this.$body.empty().append($(TPL)); this.$toc = this.$body.find(".toc"); } async refreshWithNote(note: FNote) { this.toggleInt(!!this.noteContext?.viewScope?.tocPreviousVisible); const tocLabel = note.getLabel("toc"); if (tocLabel?.value === "hide") { this.toggleInt(false); this.triggerCommand("reEvaluateRightPaneVisibility"); return; } if (!this.note || !this.noteContext?.viewScope) { return; } let $toc: JQuery | null = null; let headingCount = 0; // Check for type text unconditionally in case alwaysShowWidget is set if (this.note.type === "text") { const blob = await note.getBlob(); if (blob) { ({ $toc, headingCount } = await this.getToc(blob.content)); } } else if (this.note.type === "doc") { const $contentEl = await this.noteContext.getContentElement(); if ($contentEl) { const content = $contentEl.html(); ({ $toc, headingCount } = await this.getToc(content)); } else { console.warn("Unable to get content element for doctype"); } } if ($toc) { this.$toc.append($toc); } else { this.$toc.empty(); } if (["", "show"].includes(tocLabel?.value ?? "") || headingCount >= (options.getInt("minTocHeadings") ?? 0)) { this.toggleInt(true); this.noteContext.viewScope.tocPreviousVisible = true; } else { this.toggleInt(false); this.noteContext.viewScope.tocPreviousVisible = false; } this.triggerCommand("reEvaluateRightPaneVisibility"); } /** * Rendering formulas in strings using katex * * @param html Note's html content * @returns The HTML content with mathematical formulas rendered by KaTeX. */ async replaceMathTextWithKatax(html: string) { const mathTextRegex = /\\\(([\s\S]*?)\\\)<\/span>/g; var matches = [...html.matchAll(mathTextRegex)]; let modifiedText = html; if (matches.length > 0) { // Process all matches asynchronously for (const match of matches) { let latexCode = match[1]; let rendered; try { rendered = katex.renderToString(latexCode, { throwOnError: false }); } catch (e) { if (e instanceof ReferenceError && e.message.includes("katex is not defined")) { // Load KaTeX if it is not already loaded await libraryLoader.requireLibrary(libraryLoader.KATEX); try { rendered = katex.renderToString(latexCode, { throwOnError: false }); } catch (renderError) { console.error("KaTeX rendering error after loading library:", renderError); rendered = match[0]; // Fall back to original if error persists } } else { console.error("KaTeX rendering error:", e); rendered = match[0]; // Fall back to original on error } } // Replace the matched formula in the modified text modifiedText = modifiedText.replace(match[0], rendered); } } return modifiedText; } /** * Builds a jquery table of contents. * * @param html Note's html content * @returns ordered list table of headings, nested by heading level * with an onclick event that will cause the document to scroll to * the desired position. */ async getToc(html: string) { // Regular expression for headings

...

using non-greedy // matching and backreferences const headingTagsRegex = /]*>(.*?)<\/h\1>/gi; // Use jquery to build the table rather than html text, since it makes // it easier to set the onclick event that will be executed with the // right captured callback context let $toc = $("
    "); // Note heading 2 is the first level Trilium makes available to the note let curLevel = 2; const $ols = [$toc]; let headingCount = 0; for (let m = null, headingIndex = 0; (m = headingTagsRegex.exec(html)) !== null; headingIndex++) { // // Nest/unnest whatever necessary number of ordered lists // const newLevel = parseInt(m[1]); const levelDelta = newLevel - curLevel; if (levelDelta > 0) { // Open as many lists as newLevel - curLevel for (let i = 0; i < levelDelta; i++) { const $ol = $("
      "); $ols[$ols.length - 1].append($ol); $ols.push($ol); } } else if (levelDelta < 0) { // Close as many lists as curLevel - newLevel // be careful not to empty $ols completely, the root element should stay (could happen with a rogue h1 element) for (let i = 0; i < -levelDelta && $ols.length > 1; ++i) { $ols.pop(); } } curLevel = newLevel; // // Create the list item and set up the click callback // const headingText = await this.replaceMathTextWithKatax(m[2]); const $li = $("
    1. ").html(headingText); $li.on("click", () => this.jumpToHeading(headingIndex)); $ols[$ols.length - 1].append($li); headingCount = headingIndex; } $toc = this.pullLeft($toc); return { $toc: $toc, headingCount }; } /** * Reduce indent if a larger headings are not being used: https://github.com/zadam/trilium/issues/4363 */ pullLeft($toc: JQuery) { while (true) { const $children = $toc.children(); if ($children.length !== 1) { break; } const $first = $toc.children(":first"); if ($first[0].tagName !== "OL") { break; } $toc = $first; } return $toc; } async jumpToHeading(headingIndex: number) { if (!this.note || !this.noteContext) { return; } // A readonly note can change state to "readonly disabled // temporarily" (ie "edit this note" button) without any // intervening events, do the readonly calculation at navigation // time and not at outline creation time // See https://github.com/zadam/trilium/issues/2828 const isDocNote = this.note.type === "doc"; const isReadOnly = await this.noteContext.isReadOnly(); let $container; if (isReadOnly || isDocNote) { $container = await this.noteContext.getContentElement(); } else { const textEditor = await this.noteContext.getTextEditor(); $container = $(textEditor.sourceElement); } const headingElement = $container?.find(":header:not(section.include-note :header)")?.[headingIndex]; headingElement?.scrollIntoView({ behavior: "smooth" }); } async closeTocCommand() { if (this.noteContext?.viewScope) { this.noteContext.viewScope.tocTemporarilyHidden = true; } await this.refresh(); this.triggerCommand("reEvaluateRightPaneVisibility"); appContext.triggerEvent("reEvaluateTocWidgetVisibility", { noteId: this.noteId }); } async showTocWidgetEvent({ noteId }: EventData<"showToc">) { if (this.noteId === noteId) { await this.refresh(); this.triggerCommand("reEvaluateRightPaneVisibility"); } } async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) { await this.refresh(); } else if ( loadResults .getAttributeRows() .find((attr) => attr.type === "label" && ((attr.name ?? "").toLowerCase().includes("readonly") || attr.name === "toc") && attributeService.isAffecting(attr, this.note)) ) { await this.refresh(); } } }