.
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Trilium-TocWidget
2 |
3 | Table of contents [Trilium](https://github.com/zadam/trilium/) widget for
4 | editable and readonly text notes.
5 |
6 | ## Screenshot
7 |
8 | 
9 |
10 | ## Features
11 |
12 | - The ToC is live and automatically updated as new headers are added to the note.
13 | - Works on editable and readonly text notes.
14 | - Clicking on the ToC navigates the note.
15 | - Tested on Trilium Desktop 0.50.3
16 |
17 | ## Installation
18 | - Create a code note of type JS Frontend with the contents of [TocWidget.js](TocWidget.js)
19 | - Set the owned attributes (alt-a) to #widget
20 |
21 | ## Configuration Attributes
22 | ### In the Text Note
23 | - noTocWidget: Set on the text notes you don't want to show the ToC for
24 | ### In the Script Note
25 | - tocWidgetHeightPct: Percentage of pane height to use, 0 for dynamic, default
26 | is 30
27 | - debugLevel: Enable output to the javascript console, default is "info"
28 | (without quotes):
29 | - "error" no javascript console output
30 | - "warn" enable warn statements to the javascript console
31 | - "info" enable info and previous levels statements to the javascript console
32 | - "log" enable log and previous levels statements to the javascript console
33 | - "debug" enable debug and previous levels statements to the javascript console
34 |
35 | ## Bugs
36 | - None
37 |
38 | ## Todo
39 | - Nothing
40 |
41 | ## Discussions
42 |
43 | https://github.com/zadam/trilium/discussions/2799
44 |
--------------------------------------------------------------------------------
/TocWidget.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Table of contents widget
3 | * (c) Antonio Tejada 2022
4 | *
5 | * For text notes, it will place a table of content on the left pane, below the
6 | * tree.
7 | * - The table can't be modified directly but it's automatically updated when
8 | * new headings are added to the note
9 | * - The items in the table can be clicked to navigate the note.
10 | *
11 | * This is enabled by default for all text notes, but can be disabled by adding
12 | * the tag noTocWidget to a text note.
13 | *
14 | * By design there's no support for non-sensical or malformed constructs:
15 | * - headings inside elements (eg Trilium allows headings inside tables, but
16 | * not inside lists)
17 | * - nested headings when using raw HTML
18 | * - malformed headings when using raw HTML
19 | * - etc.
20 | *
21 | * In those cases the generated TOC may be incorrect or the navigation may lead
22 | * to the wrong heading (although what "right" means in those cases is not
23 | * clear), but it won't crash.
24 | *
25 | * See https://github.com/zadam/trilium/discussions/2799 for discussions
26 | */
27 |
28 | function getNoteAttributeValue(note, attributeType, attributeName, defaultValue) {
29 | let attribute = note.getAttribute(attributeType, attributeName);
30 |
31 | let attributeValue = (attribute != null) ? attribute.value : defaultValue;
32 |
33 | return attributeValue;
34 | }
35 |
36 | const tocWidgetHeightPct = getNoteAttributeValue(api.startNote, "label", "tocWidgetHeightPct", 30);
37 | const alwaysShowWidget = (tocWidgetHeightPct > 0);
38 | const tocWidgetHeightPctCss = alwaysShowWidget ? `height: ${tocWidgetHeightPct}%;` : "";
39 |
40 | const TEMPLATE = `
41 | Table of Contents
42 |
43 |
`;
44 |
45 | const tag = "TocWidget";
46 | const debugLevels = ["error", "warn", "info", "log", "debug"];
47 | const debugLevel = debugLevels.indexOf(getNoteAttributeValue(api.startNote, "label",
48 | "debugLevel", "info"));
49 |
50 | let warn = function() {};
51 | if (debugLevel >= debugLevels.indexOf("warn")) {
52 | warn = console.warn.bind(console, tag + ": ");
53 | }
54 |
55 | let info = function() {};
56 | if (debugLevel >= debugLevels.indexOf("info")) {
57 | info = console.info.bind(console, tag + ": ");
58 | }
59 |
60 | let log = function() {};
61 | if (debugLevel >= debugLevels.indexOf("log")) {
62 | log = console.log.bind(console, tag + ": ");
63 | }
64 |
65 | let dbg = function() {};
66 | if (debugLevel >= debugLevels.indexOf("debug")) {
67 | dbg = console.debug.bind(console, tag + ": ");
68 | }
69 |
70 | function assert(e, msg) {
71 | console.assert(e, tag + ": " + msg);
72 | }
73 |
74 | function debugbreak() {
75 | debugger;
76 | }
77 |
78 |
79 | /**
80 | * Find a heading node in the parent's children given its index.
81 | *
82 | * @param {Element} parent Parent node to find a headingIndex'th in.
83 | * @param {uint} headingIndex Index for the heading
84 | * @returns {Element|null} Heading node with the given index, null couldn't be
85 | * found (ie malformed like nested headings, etc)
86 | */
87 | function findHeadingNodeByIndex(parent, headingIndex) {
88 | log("Finding headingIndex " + headingIndex + " in parent " + parent.name);
89 | let headingNode = null;
90 | for (let i = 0; i < parent.childCount; ++i) {
91 | let child = parent.getChild(i);
92 |
93 | dbg("Inspecting node: " + child.name +
94 | ", attrs: " + Array.from(child.getAttributes()) +
95 | ", path: " + child.getPath());
96 |
97 | // Headings appear as flattened top level children in the CKEditor
98 | // document named as "heading" plus the level, eg "heading2",
99 | // "heading3", "heading2", etc and not nested wrt the heading level. If
100 | // a heading node is found, decrement the headingIndex until zero is
101 | // reached
102 | if (child.name.startsWith("heading")) {
103 | if (headingIndex == 0) {
104 | dbg("Found heading node " + child.name);
105 | headingNode = child;
106 | break;
107 | }
108 | headingIndex--;
109 | }
110 | }
111 |
112 | return headingNode;
113 | }
114 |
115 | function findHeadingElementByIndex(parent, headingIndex) {
116 | log("Finding headingIndex " + headingIndex + " in parent " + parent.innerHTML);
117 | let headingElement = null;
118 | for (let i = 0; i < parent.children.length; ++i) {
119 | let child = parent.children[i];
120 |
121 | dbg("Inspecting node: " + child.innerHTML);
122 |
123 | // Headings appear as flattened top level children in the DOM named as
124 | // "H" plus the level, eg "H2", "H3", "H2", etc and not nested wrt the
125 | // heading level. If a heading node is found, decrement the headingIndex
126 | // until zero is reached
127 | if (child.tagName.match(/H\d+/) !== null) {
128 | if (headingIndex == 0) {
129 | dbg("Found heading element " + child.tagName);
130 | headingElement = child;
131 | break;
132 | }
133 | headingIndex--;
134 | }
135 | }
136 | return headingElement;
137 | }
138 |
139 | /**
140 | * Return the active tab's element containing the HTML element that contains
141 | * a readonly note's HTML.
142 | *
143 | */
144 | function getActiveTabReadOnlyTextElement() {
145 | // The note's html is in the following hierarchy
146 | // note-split data-ntx-id=XXXX
147 | // ...
148 | // note-detail-readonly-text component
149 | //
150 | // note-detail-readonly-text-content
151 | //
152 | // Note
153 | // 1. the readonly text element is not removed but hidden when readonly is
154 | // toggled without reloading,
155 | // 2. There can also be hidden readonly text elements in inactive tabs
156 | // 3. There can be more visible readonly text elements in inactive splits
157 | log("getActiveTabReadOnlyTextElement");
158 |
159 | const activeNtxId = glob.appContext.tabManager.activeNtxId;
160 | const readOnlyTextElement = $(".note-split[data-ntx-id=" + activeNtxId +
161 | "] .note-detail-readonly-text-content");
162 |
163 | assert(readOnlyTextElement.length == 1,
164 | "Duplicated element found for " + readOnlyTextElement);
165 |
166 | return readOnlyTextElement[0];
167 | }
168 |
169 | function getActiveTabTextEditor(callback) {
170 | log("getActiveTabTextEditor");
171 | // Wrapper until this commit is available
172 | // https://github.com/zadam/trilium/commit/11578b1bc3dda7f29a91281ec28b5fe6f6c63fef
173 | api.getActiveTabTextEditor(function (textEditor) {
174 | const textEditorNtxId = textEditor.sourceElement.parentElement.component.noteContext.ntxId;
175 | if (glob.appContext.tabManager.activeNtxId == textEditorNtxId) {
176 | callback(textEditor);
177 | }
178 | });
179 | }
180 |
181 | class TocWidget extends api.NoteContextAwareWidget {
182 | get position() {
183 | log("getPosition id " + this.note?.noteId + " ntxId " + this.noteContext?.ntxId);
184 | // higher value means position towards the bottom/right
185 | return 100;
186 | }
187 |
188 | get parentWidget() {
189 | log("getParentWidget id " + this.note?.noteId + " ntxId " + this.noteContext?.ntxId);
190 | return 'left-pane';
191 | }
192 |
193 | isEnabled() {
194 | log("isEnabled id " + this.note?.noteId + " ntxId " + this.noteContext?.ntxId);
195 | return super.isEnabled()
196 | && (alwaysShowWidget || (this.note.type === 'text'))
197 | && !this.note.hasLabel('noTocWidget');
198 | }
199 |
200 | doRender() {
201 | log("doRender id " + this.note?.noteId);
202 | this.$widget = $(TEMPLATE);
203 | this.$toc = this.$widget.find('.toc');
204 | return this.$widget;
205 | }
206 |
207 | async noteSwitchedEvent(eventData) {
208 | const {noteContext, notePath } = eventData;
209 | log("noteSwitchedEvent id " + this.note?.noteId + " ntxId " + this.noteContext?.ntxId +
210 | " to id " + noteContext.note?.noteId + " ntxId " + noteContext.ntxId);
211 | return await super.noteSwitchedEvent(eventData);
212 | }
213 |
214 | async activeContextChangedEvent(eventData) {
215 | const {noteContext} = eventData;
216 | log("activeContextChangedEvent id " + this.note?.noteId + " ntxId " + this.noteContext?.ntxId +
217 | " to id " + noteContext.note?.noteId + " ntxId " + noteContext.ntxId);
218 | return await super.activeContextChangedEvent(eventData);
219 | }
220 |
221 | async noteSwitchedAndActivatedEvent(eventData) {
222 | const {noteContext, notePath} = eventData;
223 | log("noteSwitchedAndActivatedEvent id " + this.note?.noteId + " ntxId " + this.noteContext?.ntxId +
224 | " to id " + noteContext.note?.noteId + " ntxId " + noteContext.ntxId);
225 | return await super.noteSwitchedAndActivatedEvent(eventData);
226 | }
227 |
228 | async noteTypeMimeChangedEvent(eventData) {
229 | const {noteId} = eventData;
230 | log("noteTypeMimeChangedEvent id " + this.note?.noteId + " ntxId " + this.noteContext?.ntxId +
231 | " to id " + noteId);
232 | return await super.noteTypeMimeChangedEvent(eventData);
233 | }
234 |
235 | async frocaReloadedEvent(eventData) {
236 | log("frocaReloadedEvent id " + this.note?.noteId + " ntxId " + this.noteContext?.ntxId);
237 | return await super.frocaReloadedEvent(eventData);
238 | }
239 |
240 | async refreshWithNote(note) {
241 | log("refreshWithNote id " + this.note?.noteId + " ntxId " + this.noteContext?.ntxId + " with " + note.noteId);
242 | let toc = "";
243 | // Check for type text unconditionally in case alwaysShowWidget is set
244 | if (this.note.type === 'text') {
245 | const { content } = await note.getNoteComplement();
246 | toc = await this.getToc(content);
247 | }
248 |
249 | this.$toc.html(toc);
250 | }
251 |
252 | /**
253 | * Builds a jquery table of contents.
254 | *
255 | * @param {String} html Note's html content
256 | * @returns {jquery} ordered list table of headings, nested by heading level
257 | * with an onclick event that will cause the document to scroll to
258 | * the desired position.
259 | */
260 | getToc(html) {
261 | log("getToc");
262 | // Regular expression for headings ...
using non-greedy
263 | // matching and backreferences
264 | let reHeadingTags = /(.*?)<\/h\1>/g;
265 |
266 | // Use jquery to build the table rather than html text, since it makes
267 | // it easier to set the onclick event that will be executed with the
268 | // right captured callback context
269 | let $toc = $("");
270 | // Note heading 2 is the first level Trilium makes available to the note
271 | let curLevel = 2;
272 | let $ols = [$toc];
273 | let widget = this;
274 | for (let m = null, headingIndex = 0; ((m = reHeadingTags.exec(html)) !== null);
275 | ++headingIndex) {
276 | //
277 | // Nest/unnest whatever necessary number of ordered lists
278 | //
279 | let newLevel = m[1];
280 | let levelDelta = newLevel - curLevel;
281 | if (levelDelta > 0) {
282 | // Open as many lists as newLevel - curLevel
283 | for (let i = 0; i < levelDelta; ++i) {
284 | let $ol = $("");
285 | $ols[$ols.length - 1].append($ol);
286 | $ols.push($ol);
287 | }
288 | } else if (levelDelta < 0) {
289 | // Close as many lists as curLevel - newLevel
290 | for (let i = 0; i < -levelDelta; ++i) {
291 | $ols.pop();
292 | }
293 | }
294 | curLevel = newLevel;
295 |
296 | //
297 | // Create the list item and setup the click callback
298 | //
299 | let $li = $('- ' + m[2] + '
');
300 | // XXX Do this with CSS? How to inject CSS in doRender?
301 | $li.hover(function () {
302 | $(this).css("font-weight", "bold");
303 | }).mouseout(function () {
304 | $(this).css("font-weight", "normal");
305 | });
306 | $li.on("click", async function () {
307 | log("clicked");
308 | // A readonly note can change state to "readonly disabled
309 | // temporarily" (ie "edit this note" button) without any
310 | // intervening events, do the readonly calculation at navigation
311 | // time and not at outline creation time
312 | // See https://github.com/zadam/trilium/issues/2828
313 | const isReadOnly = await widget.noteContext.isReadOnly();
314 |
315 | if (isReadOnly) {
316 | let readonlyTextElement = getActiveTabReadOnlyTextElement();
317 | let headingElement = findHeadingElementByIndex(readonlyTextElement, headingIndex);
318 |
319 | if (headingElement != null) {
320 | headingElement.scrollIntoView();
321 | } else {
322 | warn("Malformed HTML, unable to navigate, TOC rendering is probably wrong too.");
323 | }
324 | } else {
325 | getActiveTabTextEditor(textEditor => {
326 | const model = textEditor.model;
327 | const doc = model.document;
328 | const root = doc.getRoot();
329 |
330 | let headingNode = findHeadingNodeByIndex(root, headingIndex);
331 |
332 | // headingNode could be null if the html was malformed or
333 | // with headings inside elements, just ignore and don't
334 | // navigate (note that the TOC rendering and other TOC
335 | // entries' navigation could be wrong too)
336 | if (headingNode != null) {
337 | // Setting the selection alone doesn't scroll to the
338 | // caret, needs to be done explicitly and outside of
339 | // the writer change callback so the scroll is
340 | // guaranteed to happen after the selection is
341 | // updated.
342 |
343 | // In addition, scrolling to a caret later in the
344 | // document (ie "forward scrolls"), only scrolls
345 | // barely enough to place the caret at the bottom of
346 | // the screen, which is a usability issue, you would
347 | // like the caret to be placed at the top or center
348 | // of the screen.
349 |
350 | // To work around that issue, first scroll to the
351 | // end of the document, then scroll to the desired
352 | // point. This causes all the scrolls to be
353 | // "backward scrolls" no matter the current caret
354 | // position, which places the caret at the top of
355 | // the screen.
356 |
357 | // XXX This could be fixed in another way by using
358 | // the underlying CKEditor5
359 | // scrollViewportToShowTarget, which allows to
360 | // provide a larger "viewportOffset", but that
361 | // has coding complications (requires calling an
362 | // internal CKEditor utils funcion and passing
363 | // an HTML element, not a CKEditor node, and
364 | // CKEditor5 doesn't seem to have a
365 | // straightforward way to convert a node to an
366 | // HTML element? (in CKEditor4 this was done
367 | // with $(node.$) )
368 |
369 | // Scroll to the end of the note to guarantee the
370 | // next scroll is a backwards scroll that places the
371 | // caret at the top of the screen
372 | model.change(writer => {
373 | writer.setSelection(root.getChild(root.childCount - 1), 0);
374 | });
375 | textEditor.editing.view.scrollToTheSelection();
376 | // Backwards scroll to the heading
377 | model.change(writer => {
378 | writer.setSelection(headingNode, 0);
379 | });
380 | textEditor.editing.view.scrollToTheSelection();
381 | } else {
382 | warn("Malformed HTML, unable to navigate, TOC rendering is probably wrong too.");
383 | }
384 | });
385 | }
386 | });
387 | $ols[$ols.length - 1].append($li);
388 | }
389 |
390 | return $toc;
391 | }
392 |
393 | async entitiesReloadedEvent(eventData) {
394 | const { loadResults } = eventData;
395 | log("entitiesReloadedEvent id " + this.note?.noteId + " ntxId " + this.noteContext?.ntxId);
396 | // The TOC needs refreshing when
397 | // - the note content changes, which loadResults.isNoteContentReloaded
398 | // reports
399 | // - the note readonly/editable changes, which
400 | // loadResults.hasAttributeRelatedChanges reports
401 | // - the note type changes and needs to show/hide (eg text to plain
402 | // text), etc which loadResults has no way to find out
403 | // so refresh unconditionally
404 | // See https://github.com/zadam/trilium/issues/2787#issuecomment-1114027030
405 | this.refresh();
406 | }
407 | }
408 |
409 | info(`Creating TocWidget debugLevel:${debugLevel} heightPct:${tocWidgetHeightPct}`);
410 | module.exports = new TocWidget();
--------------------------------------------------------------------------------