mirror of
https://github.com/zadam/trilium.git
synced 2024-11-10 09:02:48 +08:00
toc experiment
This commit is contained in:
parent
3b58b83f8b
commit
0fce2b2ea6
3 changed files with 254 additions and 1 deletions
|
@ -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",
|
||||
|
|
|
@ -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())
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
251
src/public/app/widgets/toc.js
Normal file
251
src/public/app/widgets/toc.js
Normal file
|
@ -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 <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.
|
||||
*
|
||||
* 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 = `<div style="padding: 0px; border-top: 1px solid var(--main-border-color); contain: none;">
|
||||
<span class="toc"></span>
|
||||
</div>`;
|
||||
|
||||
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 <h1>...</h1> using non-greedy
|
||||
// matching and backreferences
|
||||
let reHeadingTags = /<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
|
||||
let $toc = $("<ol>");
|
||||
// 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 = $("<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 = $('<li style="cursor:pointer">' + m[2] + '</li>');
|
||||
$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;
|
Loading…
Reference in a new issue