diff --git a/package.json b/package.json
index 3a0356b2d..a7de4a5c3 100644
--- a/package.json
+++ b/package.json
@@ -82,7 +82,7 @@
},
"devDependencies": {
"cross-env": "7.0.3",
- "electron": "16.2.1",
+ "electron": "16.2.2",
"electron-builder": "23.0.3",
"electron-packager": "15.5.0",
"electron-rebuild": "3.2.7",
diff --git a/src/public/app/layouts/desktop_layout.js b/src/public/app/layouts/desktop_layout.js
index 1b85cc71b..9cc132641 100644
--- a/src/public/app/layouts/desktop_layout.js
+++ b/src/public/app/layouts/desktop_layout.js
@@ -48,6 +48,7 @@ import BookmarkButtons from "../widgets/bookmark_buttons.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import BacklinksWidget from "../widgets/backlinks.js";
import SharedInfoWidget from "../widgets/shared_info.js";
+import TocWidget from "../widgets/toc.js";
export default class DesktopLayout {
constructor(customWidgets) {
@@ -168,6 +169,7 @@ export default class DesktopLayout {
)
.child(new RightPaneContainer()
.child(...this.customWidgets.get('right-pane'))
+ .child(new TocWidget())
)
)
);
diff --git a/src/public/app/widgets/toc.js b/src/public/app/widgets/toc.js
new file mode 100644
index 000000000..430df4fa8
--- /dev/null
+++ b/src/public/app/widgets/toc.js
@@ -0,0 +1,251 @@
+/**
+ * Table of contents widget (c) Antonio Tejada 2022
+ *
+ * For text notes, it will place a table of content on the left pane, below the
+ * tree.
+ * - The table can't be modified directly but it's automatically updated when
+ * new headings are added to the note
+ * - The items in the table can be clicked to navigate the note.
+ *
+ * This is enabled by default for all text notes, but can be disabled by adding
+ * the tag noTocWidget to a text note.
+ *
+ * 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.
+ *
+ * See https://github.com/zadam/trilium/issues/533 for discussions
+ */
+
+import NoteContextAwareWidget from "./note_context_aware_widget.js";
+import appContext from "../services/app_context.js";
+
+const TEMPLATE = `
+
+
`;
+
+const showDebug = false;
+function dbg(s) {
+ if (showDebug) {
+ console.debug("TocWidget: " + s);
+ }
+}
+
+function info(s) {
+ console.info("TocWidget: " + s);
+}
+
+function warn(s) {
+ console.warn("TocWidget: " + s);
+}
+
+function assert(e, msg) {
+ console.assert(e, msg);
+}
+
+function debugbreak() {
+ debugger;
+}
+
+/**
+ * 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) {
+ dbg("Finding headingIndex " + headingIndex + " in parent " + parent.name);
+ let headingNode = null;
+ for (let i=0, child=null; i < parent.childCount; ++i) {
+ child = parent.getChild(i);
+
+ dbg("Inspecting node: " + child.name +
+ ", attrs: " + Array.from(child.getAttributes()) +
+ ", path: " + child.getPath());
+
+ // 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) {
+ dbg("Found heading node " + child.name);
+ headingNode = child;
+ break;
+ }
+ headingIndex--;
+ }
+ }
+
+ return headingNode;
+}
+
+class TocWidget extends NoteContextAwareWidget {
+ constructor() {
+ super();
+ }
+
+ get position() {
+ dbg("getPosition");
+ // higher value means position towards the bottom/right
+ return 100;
+ }
+
+ get parentWidget() {
+ dbg("getParentWidget");
+ return 'left-pane';
+ }
+
+ isEnabled() {
+ dbg("isEnabled");
+ return super.isEnabled()
+ && this.note.type === 'text'
+ && !this.note.hasLabel('noTocWidget');
+ }
+
+ doRender() {
+ dbg("doRender");
+ this.$widget = $(TEMPLATE);
+ this.$toc = this.$widget.find('.toc');
+ return this.$widget;
+ }
+
+ async refreshWithNote(note) {
+ dbg("refreshWithNote");
+ const {content} = await note.getNoteComplement();
+ const toc = 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
+ let reHeadingTags = /(.*?)<\/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
+ let $toc = $("");
+ // Note heading 2 is the first level Trilium makes available to the note
+ let curLevel = 2;
+ let $ols = [$toc];
+ for (let m=null, headingIndex=0; ((m = reHeadingTags.exec(html)) !== null);
+ ++headingIndex) {
+ //
+ // Nest/unnest whatever necessary number of ordered lists
+ //
+ let newLevel = m[1];
+ let levelDelta = newLevel - curLevel;
+ if (levelDelta > 0) {
+ // Open as many lists as newLevel - curLevel
+ for (let i = 0; i < levelDelta; ++i) {
+ let $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 setup the click callback
+ //
+ let $li = $('- ' + m[2] + '
');
+ $li.on("click", function () {
+ dbg("clicked");
+ appContext.triggerCommand('executeInActiveEditor', {
+ callback: textEditor => {
+ const model = textEditor.model;
+ const doc = model.document;
+ const root = doc.getRoot();
+
+ let 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();
+ } else {
+ warn("Malformed HTML, unable to navigate, TOC rendering is probably wrong too.");
+ }
+ }
+ });
+ });
+ $ols[$ols.length - 1].append($li);
+ }
+ return $toc;
+ }
+
+ async entitiesReloadedEvent({loadResults}) {
+ dbg("entitiesReloadedEvent");
+ if (loadResults.isNoteContentReloaded(this.noteId)) {
+ this.refresh();
+ }
+ }
+}
+
+info("Creating TocWidget");
+export default TocWidget;