266 lines
8.7 KiB
JavaScript
Raw Normal View History

2022-05-29 21:44:26 +02:00
/**
* 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
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.
*
* 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 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";
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
}
.toc ol {
2022-05-30 17:45:59 +02:00
padding-left: 25px;
2022-05-29 21:44:26 +02:00
}
.toc > ol {
padding-left: 20px;
}
.toc li {
cursor: pointer;
}
.toc li:hover {
font-weight: bold;
2022-05-29 21:44:26 +02:00
}
.close-toc {
position: absolute;
top: 2px;
right: 2px;
}
2022-05-29 21:44:26 +02:00
</style>
<span class="toc"></span>
</div>`;
export default class TocWidget extends RightPanelWidget {
constructor() {
super();
2022-05-29 21:44:26 +02:00
this.closeTocButton = new CloseTocButton();
this.child(this.closeTocButton);
2022-05-29 21:44:26 +02:00
}
get widgetTitle() {
return "Table of Contents";
}
isEnabled() {
return super.isEnabled()
&& this.note.type === 'text'
&& !this.noteContext.viewScope.tocTemporarilyHidden
&& this.noteContext.viewScope.viewMode === 'default';
2022-05-29 21:44:26 +02:00
}
async doRenderBody() {
this.$body.empty().append($(TPL));
this.$toc = this.$body.find('.toc');
this.$body.find('.toc-widget').append(this.closeTocButton.render());
2022-05-29 21:44:26 +02:00
}
async refreshWithNote(note) {
const tocLabel = note.getLabel('toc');
if (tocLabel?.value === 'hide') {
this.toggleInt(false);
this.triggerCommand("reEvaluateRightPaneVisibility");
return;
}
2022-05-30 17:45:59 +02:00
let $toc = "", headingCount = 0;
2022-05-29 21:44:26 +02:00
// Check for type text unconditionally in case alwaysShowWidget is set
if (this.note.type === 'text') {
const { content } = await note.getNoteComplement();
2022-05-30 17:45:59 +02:00
({$toc, headingCount} = await this.getToc(content));
2022-05-29 21:44:26 +02:00
}
2022-05-30 17:45:59 +02:00
this.$toc.html($toc);
this.toggleInt(
["", "show"].includes(tocLabel?.value)
|| headingCount >= options.getInt('minTocHeadings')
);
this.triggerCommand("reEvaluateRightPaneVisibility");
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.
*/
getToc(html) {
// 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
const $toc = $("<ol>");
// 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;
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
//
const headingText = $("<div>").html(m[2]).text();
const $li = $('<li>').text(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
}
2022-05-30 17:45:59 +02:00
return {
$toc,
headingCount
};
}
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();
if (isReadOnly) {
const $container = await this.noteContext.getContentElement();
const headingElement = $container.find(":header")[headingIndex];
2022-05-30 17:45:59 +02:00
if (headingElement != null) {
headingElement.scrollIntoView({ behavior: "smooth" });
2022-05-30 17:45:59 +02:00
}
} else {
const textEditor = await this.noteContext.getTextEditor();
const model = textEditor.model;
const doc = model.document;
const root = doc.getRoot();
const headingNode = findHeadingNodeByIndex(root, headingIndex);
// headingNode could be null if the html was malformed or
// with headings inside elements, just ignore and don't
// navigate (note that the TOC rendering and other TOC
// entries' navigation could be wrong too)
if (headingNode != null) {
2023-05-17 10:18:58 +00:00
$(textEditor.editing.view.domRoots.values().next().value).find(':header')[headingIndex].scrollIntoView({
behavior: 'smooth'
2022-05-30 17:45:59 +02:00
});
}
}
2022-05-29 21:44:26 +02:00
}
async closeTocCommand() {
this.noteContext.viewScope.tocTemporarilyHidden = true;
await this.refresh();
this.triggerCommand('reEvaluateRightPaneVisibility');
}
2022-05-29 21:44:26 +02:00
async entitiesReloadedEvent({loadResults}) {
2022-05-30 17:45:59 +02:00
if (loadResults.isNoteContentReloaded(this.noteId)) {
await this.refresh();
} else if (loadResults.getAttributes().find(attr => attr.type === 'label'
&& (attr.name.toLowerCase().includes('readonly') || attr.name === 'toc')
2022-05-30 17:45:59 +02:00
&& attributeService.isAffecting(attr, this.note))) {
2022-05-29 21:44:26 +02:00
await this.refresh();
}
}
}
/**
* Find a heading node in the parent's children given its index.
*
* @param {Element} parent Parent node to find a headingIndex'th in.
* @param {uint} headingIndex Index for the heading
* @returns {Element|null} Heading node with the given index, null couldn't be
* found (ie malformed like nested headings, etc.)
*/
function findHeadingNodeByIndex(parent, headingIndex) {
let headingNode = null;
for (let i = 0; i < parent.childCount; ++i) {
let child = parent.getChild(i);
// Headings appear as flattened top level children in the CKEditor
// document named as "heading" plus the level, eg "heading2",
// "heading3", "heading2", etc. and not nested wrt the heading level. If
// a heading node is found, decrement the headingIndex until zero is
// reached
if (child.name.startsWith("heading")) {
if (headingIndex === 0) {
headingNode = child;
break;
}
headingIndex--;
}
}
return headingNode;
}
class CloseTocButton extends OnClickButtonWidget {
constructor() {
super();
this.icon("bx-x")
.title("Close TOC")
.titlePlacement("bottom")
.onClick((widget, e) => {
e.stopPropagation();
widget.triggerCommand("closeToc");
})
.class("icon-action close-toc");
}
}