mirror of
				https://github.com/zadam/trilium.git
				synced 2025-11-03 20:06:08 +01:00 
			
		
		
		
	TOC widget WIP
This commit is contained in:
		@@ -49,6 +49,7 @@ import NoteWrapperWidget from "../widgets/note_wrapper.js";
 | 
				
			|||||||
import BacklinksWidget from "../widgets/backlinks.js";
 | 
					import BacklinksWidget from "../widgets/backlinks.js";
 | 
				
			||||||
import SharedInfoWidget from "../widgets/shared_info.js";
 | 
					import SharedInfoWidget from "../widgets/shared_info.js";
 | 
				
			||||||
import FindWidget from "../widgets/find.js";
 | 
					import FindWidget from "../widgets/find.js";
 | 
				
			||||||
 | 
					import TocWidget from "../widgets/toc.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class DesktopLayout {
 | 
					export default class DesktopLayout {
 | 
				
			||||||
    constructor(customWidgets) {
 | 
					    constructor(customWidgets) {
 | 
				
			||||||
@@ -169,6 +170,7 @@ export default class DesktopLayout {
 | 
				
			|||||||
                        .child(...this.customWidgets.get('center-pane'))
 | 
					                        .child(...this.customWidgets.get('center-pane'))
 | 
				
			||||||
                    )
 | 
					                    )
 | 
				
			||||||
                    .child(new RightPaneContainer()
 | 
					                    .child(new RightPaneContainer()
 | 
				
			||||||
 | 
					                        .child(new TocWidget())
 | 
				
			||||||
                        .child(...this.customWidgets.get('right-pane'))
 | 
					                        .child(...this.customWidgets.get('right-pane'))
 | 
				
			||||||
                    )
 | 
					                    )
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,6 +9,9 @@ const WIDGET_TPL = `
 | 
				
			|||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
</div>`;
 | 
					</div>`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * TODO: rename, it's not collapsible anymore
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
export default class CollapsibleWidget extends NoteContextAwareWidget {
 | 
					export default class CollapsibleWidget extends NoteContextAwareWidget {
 | 
				
			||||||
    get widgetTitle() { return "Untitled widget"; }
 | 
					    get widgetTitle() { return "Untitled widget"; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										258
									
								
								src/public/app/widgets/toc.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								src/public/app/widgets/toc.js
									
									
									
									
									
										Normal file
									
								
							@@ -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 <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";
 | 
				
			||||||
 | 
					import CollapsibleWidget from "./collapsible_widget.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const TPL = `<div class="toc-widget">
 | 
				
			||||||
 | 
					    <style>
 | 
				
			||||||
 | 
					        .toc-widget {
 | 
				
			||||||
 | 
					            padding: 10px;
 | 
				
			||||||
 | 
					            contain: none; 
 | 
				
			||||||
 | 
					            overflow:auto;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        .toc ol {
 | 
				
			||||||
 | 
					            padding-left: 20px;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        .toc > ol {
 | 
				
			||||||
 | 
					            padding-left: 0;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    </style>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <span class="toc"></span>
 | 
				
			||||||
 | 
					</div>`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * 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 <h1>...</h1> using non-greedy
 | 
				
			||||||
 | 
					        // matching and backreferences
 | 
				
			||||||
 | 
					        const headingTagsRegex = /<h(\d+)>(.*?)<\/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 = $("<ol>");
 | 
				
			||||||
 | 
					        // 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 = $("<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 = $('<li style="cursor:pointer">' + m[2] + '</li>');
 | 
				
			||||||
 | 
					            // 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();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user