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 = $('
    1. ' + m[2] + '
    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(); + } + } +}