diff --git a/src/public/app/layouts/desktop_layout.js b/src/public/app/layouts/desktop_layout.js
index 2723076ce..446f19a75 100644
--- a/src/public/app/layouts/desktop_layout.js
+++ b/src/public/app/layouts/desktop_layout.js
@@ -49,6 +49,7 @@ import NoteWrapperWidget from "../widgets/note_wrapper.js";
import BacklinksWidget from "../widgets/backlinks.js";
import SharedInfoWidget from "../widgets/shared_info.js";
import FindWidget from "../widgets/find.js";
+import TocWidget from "../widgets/toc.js";
export default class DesktopLayout {
constructor(customWidgets) {
@@ -169,6 +170,7 @@ export default class DesktopLayout {
.child(...this.customWidgets.get('center-pane'))
)
.child(new RightPaneContainer()
+ .child(new TocWidget())
.child(...this.customWidgets.get('right-pane'))
)
)
diff --git a/src/public/app/widgets/collapsible_widget.js b/src/public/app/widgets/collapsible_widget.js
index e5563fb7f..bbe60345b 100644
--- a/src/public/app/widgets/collapsible_widget.js
+++ b/src/public/app/widgets/collapsible_widget.js
@@ -9,6 +9,9 @@ const WIDGET_TPL = `
`;
+/**
+ * TODO: rename, it's not collapsible anymore
+ */
export default class CollapsibleWidget extends NoteContextAwareWidget {
get widgetTitle() { return "Untitled widget"; }
diff --git a/src/public/app/widgets/toc.js b/src/public/app/widgets/toc.js
new file mode 100644
index 000000000..5a181686f
--- /dev/null
+++ b/src/public/app/widgets/toc.js
@@ -0,0 +1,258 @@
+/**
+ * Table of contents widget
+ * (c) Antonio Tejada 2022
+ *
+ * By design there's no support for non-sensical or malformed constructs:
+ * - headings inside elements (eg 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 attributeService from "../services/attributes.js";
+import CollapsibleWidget from "./collapsible_widget.js";
+
+const TPL = `
+
+
+
+
`;
+
+/**
+ * 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;
+}
+
+function findHeadingElementByIndex(parent, headingIndex) {
+ let headingElement = null;
+ for (let i = 0; i < parent.children.length; ++i) {
+ const child = parent.children[i];
+ // Headings appear as flattened top level children in the DOM named as
+ // "H" plus the level, eg "H2", "H3", "H2", etc and not nested wrt the
+ // heading level. If a heading node is found, decrement the headingIndex
+ // until zero is reached
+ if (child.tagName.match(/H\d+/) !== null) {
+ if (headingIndex === 0) {
+ headingElement = child;
+ break;
+ }
+ headingIndex--;
+ }
+ }
+ return headingElement;
+}
+
+export default class TocWidget extends CollapsibleWidget {
+ get widgetTitle() {
+ return "Table of Contents";
+ }
+
+ isEnabled() {
+ return super.isEnabled()
+ && this.note.type === 'text'
+ && !this.note.hasLabel('noTocWidget');
+ }
+
+ async doRenderBody() {
+ this.$body.empty().append($(TPL));
+ this.$toc = this.$body.find('.toc');
+ }
+
+ async refreshWithNote(note) {
+ let toc = "";
+ // Check for type text unconditionally in case alwaysShowWidget is set
+ if (this.note.type === 'text') {
+ const { content } = await note.getNoteComplement();
+ toc = await this.getToc(content);
+ }
+
+ this.$toc.html(toc);
+ }
+
+ /**
+ * Builds a jquery table of contents.
+ *
+ * @param {String} html Note's html content
+ * @returns {jQuery} ordered list table of headings, nested by heading level
+ * with an onclick event that will cause the document to scroll to
+ * the desired position.
+ */
+ getToc(html) {
+ // Regular expression for headings ...
using non-greedy
+ // matching and backreferences
+ const headingTagsRegex = /(.*?)<\/h\1>/g;
+
+ // 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 = $("");
+ // Note heading 2 is the first level Trilium makes available to the note
+ let curLevel = 2;
+ const $ols = [$toc];
+ for (let m = null, headingIndex = 0; ((m = headingTagsRegex.exec(html)) !== null); ++headingIndex) {
+ //
+ // 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 = $("");
+ $ols[$ols.length - 1].append($ol);
+ $ols.push($ol);
+ }
+ } else if (levelDelta < 0) {
+ // Close as many lists as curLevel - newLevel
+ for (let i = 0; i < -levelDelta; ++i) {
+ $ols.pop();
+ }
+ }
+ curLevel = newLevel;
+
+ //
+ // Create the list item and set up the click callback
+ //
+ const $li = $('- ' + m[2] + '
');
+ // XXX Do this with CSS? How to inject CSS in doRender?
+ $li.hover(function () {
+ $(this).css("font-weight", "bold");
+ }).mouseout(function () {
+ $(this).css("font-weight", "normal");
+ });
+ $li.on("click", async () => {
+ // 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 readonlyTextElement = await this.noteContext.getContentElement();
+ const headingElement = findHeadingElementByIndex(readonlyTextElement, headingIndex);
+
+ if (headingElement != null) {
+ headingElement.scrollIntoView();
+ }
+ } 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) {
+ // Setting the selection alone doesn't scroll to the
+ // caret, needs to be done explicitly and outside of
+ // the writer change callback so the scroll is
+ // guaranteed to happen after the selection is
+ // updated.
+
+ // In addition, scrolling to a caret later in the
+ // document (ie "forward scrolls"), only scrolls
+ // barely enough to place the caret at the bottom of
+ // the screen, which is a usability issue, you would
+ // like the caret to be placed at the top or center
+ // of the screen.
+
+ // To work around that issue, first scroll to the
+ // end of the document, then scroll to the desired
+ // point. This causes all the scrolls to be
+ // "backward scrolls" no matter the current caret
+ // position, which places the caret at the top of
+ // the screen.
+
+ // XXX This could be fixed in another way by using
+ // the underlying CKEditor5
+ // scrollViewportToShowTarget, which allows to
+ // provide a larger "viewportOffset", but that
+ // has coding complications (requires calling an
+ // internal CKEditor utils funcion and passing
+ // an HTML element, not a CKEditor node, and
+ // CKEditor5 doesn't seem to have a
+ // straightforward way to convert a node to an
+ // HTML element? (in CKEditor4 this was done
+ // with $(node.$) )
+
+ // Scroll to the end of the note to guarantee the
+ // next scroll is a backwards scroll that places the
+ // caret at the top of the screen
+ model.change(writer => {
+ writer.setSelection(root.getChild(root.childCount - 1), 0);
+ });
+ textEditor.editing.view.scrollToTheSelection();
+ // Backwards scroll to the heading
+ model.change(writer => {
+ writer.setSelection(headingNode, 0);
+ });
+ textEditor.editing.view.scrollToTheSelection();
+ }
+ }
+ });
+ $ols[$ols.length - 1].append($li);
+ }
+
+ return $toc;
+ }
+
+ async entitiesReloadedEvent({loadResults}) {
+ if (loadResults.isNoteContentReloaded(this.noteId)
+ || loadResults.getAttributes().find(attr => attr.type === 'label'
+ && attr.name.toLowerCase().includes('readonly')
+ && attributeService.isAffecting(attr, this.note))) {
+
+ await this.refresh();
+ }
+ }
+}