From 0fce2b2ea607aa5c2692fc9ac6f3a8ef83cd6fad Mon Sep 17 00:00:00 2001 From: zadam Date: Fri, 22 Apr 2022 00:07:37 +0200 Subject: [PATCH] toc experiment --- package.json | 2 +- src/public/app/layouts/desktop_layout.js | 2 + src/public/app/widgets/toc.js | 251 +++++++++++++++++++++++ 3 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/public/app/widgets/toc.js 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 = $('
    1. ' + m[2] + '
    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;