mirror of
				https://github.com/zadam/trilium.git
				synced 2025-11-03 20:06:08 +01:00 
			
		
		
		
	chore(monorepo): relocate client files
This commit is contained in:
		
							
								
								
									
										350
									
								
								apps/client/src/widgets/toc.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										350
									
								
								apps/client/src/widgets/toc.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,350 @@
 | 
			
		||||
/**
 | 
			
		||||
 * 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 <H2><H3></H3></H2>
 | 
			
		||||
 * - malformed headings when using raw HTML <H2></H3></H2><H3>
 | 
			
		||||
 * - 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 = /*html*/`<div class="toc-widget">
 | 
			
		||||
    <style>
 | 
			
		||||
        .toc-widget {
 | 
			
		||||
            padding: 10px;
 | 
			
		||||
            contain: none;
 | 
			
		||||
            overflow: auto;
 | 
			
		||||
            position: relative;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .toc ol {
 | 
			
		||||
            padding-left: 25px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .toc > ol {
 | 
			
		||||
            padding-left: 20px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .toc li {
 | 
			
		||||
            cursor: pointer;
 | 
			
		||||
            text-align: justify;
 | 
			
		||||
            word-wrap: break-word;
 | 
			
		||||
            hyphens: auto;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .toc li:hover {
 | 
			
		||||
            font-weight: bold;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
 | 
			
		||||
    <span class="toc"></span>
 | 
			
		||||
</div>`;
 | 
			
		||||
 | 
			
		||||
interface Toc {
 | 
			
		||||
    $toc: JQuery<HTMLElement>;
 | 
			
		||||
    headingCount: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default class TocWidget extends RightPanelWidget {
 | 
			
		||||
 | 
			
		||||
    private $toc!: JQuery<HTMLElement>;
 | 
			
		||||
    private tocLabelValue?: string | null;
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
 | 
			
		||||
        this.tocLabelValue = note.getLabelValue("toc");
 | 
			
		||||
 | 
			
		||||
        if (this.tocLabelValue === "hide") {
 | 
			
		||||
            this.toggleInt(false);
 | 
			
		||||
            this.triggerCommand("reEvaluateRightPaneVisibility");
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!this.note || !this.noteContext?.viewScope) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check for type text unconditionally in case alwaysShowWidget is set
 | 
			
		||||
        if (this.note.type === "text") {
 | 
			
		||||
            const blob = await note.getBlob();
 | 
			
		||||
            if (blob) {
 | 
			
		||||
                const toc = await this.getToc(blob.content);
 | 
			
		||||
                this.#updateToc(toc);
 | 
			
		||||
            }
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.note.type === "doc") {
 | 
			
		||||
            /**
 | 
			
		||||
             * For document note types, we obtain the content directly from the DOM since it allows us to obtain processed data without
 | 
			
		||||
             * requesting data twice. However, when immediately navigating to a new note the new document is not yet attached to the hierarchy,
 | 
			
		||||
             * resulting in an empty TOC. The fix is to simply wait for it to pop up.
 | 
			
		||||
             */
 | 
			
		||||
            setTimeout(async () => {
 | 
			
		||||
                const $contentEl = await this.noteContext?.getContentElement();
 | 
			
		||||
                if ($contentEl) {
 | 
			
		||||
                    const content = $contentEl.html();
 | 
			
		||||
                    const toc = await this.getToc(content);
 | 
			
		||||
                    this.#updateToc(toc);
 | 
			
		||||
                } else {
 | 
			
		||||
                    console.warn("Unable to get content element for doctype");
 | 
			
		||||
                }
 | 
			
		||||
            }, 10);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #updateToc({ $toc, headingCount }: Toc) {
 | 
			
		||||
        this.$toc.empty();
 | 
			
		||||
        if ($toc) {
 | 
			
		||||
            this.$toc.append($toc);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const tocLabelValue = this.tocLabelValue;
 | 
			
		||||
 | 
			
		||||
        const visible = tocLabelValue === "" || tocLabelValue === "show" || headingCount >= (options.getInt("minTocHeadings") ?? 0);
 | 
			
		||||
        this.toggleInt(visible);
 | 
			
		||||
        if (this.noteContext?.viewScope) {
 | 
			
		||||
            this.noteContext.viewScope.tocPreviousVisible = visible;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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 = /<span class="math-tex">\\\(([\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): Promise<Toc> {
 | 
			
		||||
        // Regular expression for headings <h1>...</h1> using non-greedy
 | 
			
		||||
        // matching and backreferences
 | 
			
		||||
        const headingTagsRegex = /<h(\d+)[^>]*>(.*?)<\/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 = $("<ol>");
 | 
			
		||||
        // 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 = $("<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 = $("<li>").html(headingText);
 | 
			
		||||
            $li.on("click", () => this.jumpToHeading(headingIndex));
 | 
			
		||||
            $ols[$ols.length - 1].append($li);
 | 
			
		||||
            headingCount = headingIndex;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $toc = this.pullLeft($toc);
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            $toc,
 | 
			
		||||
            headingCount
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Reduce indent if a larger headings are not being used: https://github.com/zadam/trilium/issues/4363
 | 
			
		||||
     */
 | 
			
		||||
    pullLeft($toc: JQuery<HTMLElement>) {
 | 
			
		||||
        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<"showTocWidget">) {
 | 
			
		||||
        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();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user