313 lines
11 KiB
JavaScript
Raw Normal View History

2022-05-29 21:44:26 +02:00
/**
* Table of contents widget
* (c) Antonio Tejada 2022
*
2023-06-30 11:18:34 +02:00
* By design, there's no support for nonsensical or malformed constructs:
* - headings inside elements (e.g. Trilium allows headings inside tables, but
2022-05-29 21:44:26 +02:00
* not inside lists)
* - nested headings when using raw HTML <H2><H3></H3></H2>
* - malformed headings when using raw HTML <H2></H3></H2><H3>
* - etc.
*
2023-06-30 11:18:34 +02:00
* In those cases, the generated TOC may be incorrect, or the navigation may lead
2022-05-29 21:44:26 +02:00
* to the wrong heading (although what "right" means in those cases is not
* clear), but it won't crash.
*/
2024-10-15 15:12:09 +08:00
import { t } from "../services/i18n.js";
2022-05-29 21:44:26 +02:00
import attributeService from "../services/attributes.js";
2023-01-03 14:31:46 +01:00
import RightPanelWidget from "./right_panel_widget.js";
import options from "../services/options.js";
import OnClickButtonWidget from "./buttons/onclick_button.js";
import appContext from "../components/app_context.js";
import libraryLoader from "../services/library_loader.js";
2022-05-29 21:44:26 +02:00
const TPL = `<div class="toc-widget">
<style>
.toc-widget {
padding: 10px;
contain: none;
overflow: auto;
position: relative;
2022-05-29 21:44:26 +02:00
}
2022-05-29 21:44:26 +02:00
.toc ol {
2022-05-30 17:45:59 +02:00
padding-left: 25px;
2022-05-29 21:44:26 +02:00
}
2022-05-29 21:44:26 +02:00
.toc > ol {
padding-left: 20px;
}
.toc li {
cursor: pointer;
2023-06-03 14:43:20 +08:00
text-align: justify;
word-wrap: break-word;
hyphens: auto;
}
.toc li:hover {
font-weight: bold;
2022-05-29 21:44:26 +02:00
}
</style>
<span class="toc"></span>
</div>`;
export default class TocWidget extends RightPanelWidget {
2022-05-29 21:44:26 +02:00
get widgetTitle() {
2024-10-15 15:12:09 +08:00
return t("toc.table_of_contents");
2022-05-29 21:44:26 +02:00
}
get widgetButtons() {
return [
new OnClickButtonWidget()
.icon("bx-cog")
2024-10-15 15:12:09 +08:00
.title(t("toc.options"))
.titlePlacement("left")
2025-01-09 18:07:02 +02:00
.onClick(() => appContext.tabManager.openContextWithNote("_optionsTextNotes", { activate: true }))
.class("icon-action"),
new OnClickButtonWidget()
.icon("bx-x")
.titlePlacement("left")
2025-01-09 18:07:02 +02:00
.onClick((widget) => widget.triggerCommand("closeToc"))
.class("icon-action")
];
}
2022-05-29 21:44:26 +02:00
isEnabled() {
if (!super.isEnabled()) {
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";
2022-05-29 21:44:26 +02:00
}
async doRenderBody() {
this.$body.empty().append($(TPL));
2025-01-09 18:07:02 +02:00
this.$toc = this.$body.find(".toc");
2022-05-29 21:44:26 +02:00
}
async refreshWithNote(note) {
2023-06-29 23:32:19 +02:00
/*The reason for adding tocPreviousVisible is to record whether the previous state of the toc is hidden or displayed,
2025-01-09 18:07:02 +02:00
* and then let it be displayed/hidden at the initial time. If there is no such value,
* when the right panel needs to display highlighttext but not toc, every time the note content is changed,
* toc will appear and then close immediately, because getToc(html) function will consume time*/
2023-06-29 23:32:19 +02:00
this.toggleInt(!!this.noteContext.viewScope.tocPreviousVisible);
2025-01-09 18:07:02 +02:00
const tocLabel = note.getLabel("toc");
2025-01-09 18:07:02 +02:00
if (tocLabel?.value === "hide") {
this.toggleInt(false);
this.triggerCommand("reEvaluateRightPaneVisibility");
return;
}
2025-01-09 18:07:02 +02:00
let $toc = "",
headingCount = 0;
2022-05-29 21:44:26 +02:00
// Check for type text unconditionally in case alwaysShowWidget is set
2025-01-09 18:07:02 +02:00
if (this.note.type === "text") {
2023-05-05 16:37:39 +02:00
const { content } = await note.getBlob();
2025-01-09 18:07:02 +02:00
({ $toc, headingCount } = await this.getToc(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");
}
2022-05-29 21:44:26 +02:00
}
2022-05-30 17:45:59 +02:00
this.$toc.html($toc);
2025-01-09 18:07:02 +02:00
if (["", "show"].includes(tocLabel?.value) || headingCount >= options.getInt("minTocHeadings")) {
this.toggleInt(true);
2025-01-09 18:07:02 +02:00
this.noteContext.viewScope.tocPreviousVisible = true;
} else {
this.toggleInt(false);
2025-01-09 18:07:02 +02:00
this.noteContext.viewScope.tocPreviousVisible = false;
}
this.triggerCommand("reEvaluateRightPaneVisibility");
2022-05-29 21:44:26 +02:00
}
/**
* Rendering formulas in strings using katex
*
* @param {string} html Note's html content
* @returns {string} The HTML content with mathematical formulas rendered by KaTeX.
2024-10-15 15:12:09 +08:00
*/
async replaceMathTextWithKatax(html) {
const mathTextRegex = /<span class="math-tex">\\\(([\s\S]*?)\\\)<\/span>/g;
var matches = [...html.matchAll(mathTextRegex)];
let modifiedText = html;
2024-10-15 15:12:09 +08:00
if (matches.length > 0) {
// Process all matches asynchronously
for (const match of matches) {
let latexCode = match[1];
let rendered;
2024-10-15 15:12:09 +08:00
try {
rendered = katex.renderToString(latexCode, {
throwOnError: false
});
} catch (e) {
2025-01-09 18:07:02 +02:00
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
}
}
2024-10-15 15:12:09 +08:00
// Replace the matched formula in the modified text
modifiedText = modifiedText.replace(match[0], rendered);
}
}
return modifiedText;
}
2022-05-29 21:44:26 +02:00
/**
* Builds a jquery table of contents.
*
* @param {string} html Note's html content
2022-05-30 17:45:59 +02:00
* @returns {$toc: jQuery, headingCount: integer} ordered list table of headings, nested by heading level
2022-05-29 21:44:26 +02:00
* with an onclick event that will cause the document to scroll to
* the desired position.
*/
async getToc(html) {
2022-05-29 21:44:26 +02:00
// Regular expression for headings <h1>...</h1> using non-greedy
// matching and backreferences
const headingTagsRegex = /<h(\d+)[^>]*>(.*?)<\/h\1>/gi;
2022-05-29 21:44:26 +02:00
// 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>");
2022-05-29 21:44:26 +02:00
// Note heading 2 is the first level Trilium makes available to the note
let curLevel = 2;
const $ols = [$toc];
2022-05-30 17:45:59 +02:00
let headingCount;
2025-01-09 18:07:02 +02:00
for (let m = null, headingIndex = 0; (m = headingTagsRegex.exec(html)) !== null; headingIndex++) {
2022-05-29 21:44:26 +02:00
//
// Nest/unnest whatever necessary number of ordered lists
//
const newLevel = 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
2023-01-10 16:16:13 +01:00
// 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) {
2022-05-29 21:44:26 +02:00
$ols.pop();
}
}
curLevel = newLevel;
//
// Create the list item and set up the click callback
//
2025-01-09 18:07:02 +02:00
const headingText = await this.replaceMathTextWithKatax(m[2]);
const $li = $("<li>").html(headingText);
2022-05-30 17:45:59 +02:00
$li.on("click", () => this.jumpToHeading(headingIndex));
2022-05-29 21:44:26 +02:00
$ols[$ols.length - 1].append($li);
2022-05-30 17:45:59 +02:00
headingCount = headingIndex;
2022-05-29 21:44:26 +02:00
}
$toc = this.pullLeft($toc);
2022-05-30 17:45:59 +02:00
return {
$toc,
headingCount
};
}
/**
* Reduce indent if a larger headings are not being used: https://github.com/zadam/trilium/issues/4363
*/
pullLeft($toc) {
while (true) {
const $children = $toc.children();
if ($children.length !== 1) {
break;
}
const $first = $toc.children(":first");
2025-01-09 18:07:02 +02:00
if ($first[0].tagName !== "OL") {
break;
}
$toc = $first;
}
return $toc;
}
2022-05-30 17:45:59 +02:00
async jumpToHeading(headingIndex) {
// 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 isReadOnly = await this.noteContext.isReadOnly();
2023-09-22 05:52:06 -04:00
let $container;
2022-05-30 17:45:59 +02:00
if (isReadOnly) {
2023-09-22 05:52:06 -04:00
$container = await this.noteContext.getContentElement();
2022-05-30 17:45:59 +02:00
} else {
const textEditor = await this.noteContext.getTextEditor();
2023-09-22 05:52:06 -04:00
$container = $(textEditor.sourceElement);
2022-05-30 17:45:59 +02:00
}
2023-09-22 05:52:06 -04:00
const headingElement = $container?.find(":header:not(section.include-note :header)")?.[headingIndex];
headingElement?.scrollIntoView({ behavior: "smooth" });
2022-05-29 21:44:26 +02:00
}
async closeTocCommand() {
this.noteContext.viewScope.tocTemporarilyHidden = true;
await this.refresh();
2025-01-09 18:07:02 +02:00
this.triggerCommand("reEvaluateRightPaneVisibility");
appContext.triggerEvent("reEvaluateTocWidgetVisibility", { noteId: this.noteId });
}
async showTocWidgetEvent({ noteId }) {
if (this.noteId === noteId) {
await this.refresh();
2025-01-09 18:07:02 +02:00
this.triggerCommand("reEvaluateRightPaneVisibility");
}
}
async entitiesReloadedEvent({ loadResults }) {
2022-05-30 17:45:59 +02:00
if (loadResults.isNoteContentReloaded(this.noteId)) {
await this.refresh();
2025-01-09 18:07:02 +02:00
} else if (
loadResults
.getAttributeRows()
.find((attr) => attr.type === "label" && (attr.name.toLowerCase().includes("readonly") || attr.name === "toc") && attributeService.isAffecting(attr, this.note))
) {
2022-05-29 21:44:26 +02:00
await this.refresh();
}
}
}