mirror of
https://github.com/zadam/trilium.git
synced 2025-03-01 14:22:32 +01:00
TOC widget WIP
This commit is contained in:
parent
01155ad535
commit
cce3f9a700
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user