TOC widget WIP

This commit is contained in:
zadam 2022-05-29 21:44:26 +02:00
parent 01155ad535
commit cce3f9a700
3 changed files with 263 additions and 0 deletions

View file

@ -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'))
)
)

View file

@ -9,6 +9,9 @@ const WIDGET_TPL = `
</div>
</div>`;
/**
* TODO: rename, it's not collapsible anymore
*/
export default class CollapsibleWidget extends NoteContextAwareWidget {
get widgetTitle() { return "Untitled widget"; }

View 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();
}
}
}