feat(toc): Collapsible TOC

This commit is contained in:
SiriusXT 2025-05-16 21:25:10 +08:00
parent e946bde939
commit c80d7a3ec3
3 changed files with 128 additions and 6 deletions

View File

@ -58,6 +58,7 @@ export interface ViewScope {
* toc will appear and then close immediately, because getToc(html) function will consume time * toc will appear and then close immediately, because getToc(html) function will consume time
*/ */
tocPreviousVisible?: boolean; tocPreviousVisible?: boolean;
tocCollapsedHeadings?: Set<string>;
} }
interface CreateLinkOptions { interface CreateLinkOptions {

View File

@ -13,7 +13,7 @@ const TPL = /*html*/`
height: 300px; height: 300px;
} }
.open-full-button, .collapse-button { .note-map-ribbon-widget .open-full-button, .note-map-ribbon-widget .collapse-button {
position: absolute; position: absolute;
right: 5px; right: 5px;
bottom: 5px; bottom: 5px;

View File

@ -29,23 +29,68 @@ const TPL = /*html*/`<div class="toc-widget">
contain: none; contain: none;
overflow: auto; overflow: auto;
position: relative; position: relative;
padding-left:0px !important;
} }
.toc ol { .toc ol {
padding-left: 25px; position: relative;
overflow: hidden;
padding-left: 20px;
transition: max-height 0.3s ease;
} }
.toc > ol { .toc > ol {
padding-left: 20px; padding-left: 0px;
}
.toc li.collapsed + ol {
display:none;
}
.toc li + ol:before {
content: "";
position: absolute;
height: 100%;
left: 17px;
border-left: 1px solid var(--main-border-color);
} }
.toc li { .toc li {
display: flex;
position: relative;
list-style: none;
align-items: center;
padding-left: 7px;
cursor: pointer; cursor: pointer;
text-align: justify; text-align: justify;
word-wrap: break-word; word-wrap: break-word;
hyphens: auto; hyphens: auto;
} }
.toc li .collapse-button {
display: flex;
position: relative;
width: 20px;
height: 20px;
flex-shrink: 0;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.toc li.collapsed .collapse-button {
transform: rotate(-90deg);
}
.toc li .item-content {
margin-left: 28px;
flex: 1;
}
.toc li .collapse-button + .item-content {
margin-left: 8px;
}
.toc li:hover { .toc li:hover {
font-weight: bold; font-weight: bold;
} }
@ -231,6 +276,14 @@ export default class TocWidget extends RightPanelWidget {
// Note heading 2 is the first level Trilium makes available to the note // Note heading 2 is the first level Trilium makes available to the note
let curLevel = 2; let curLevel = 2;
const $ols = [$toc]; const $ols = [$toc];
let $previousLi: JQuery<HTMLElement> | undefined;
if (!(this.noteContext?.viewScope?.tocCollapsedHeadings instanceof Set)) {
this.noteContext!.viewScope!.tocCollapsedHeadings = new Set<string>();
}
const tocCollapsedHeadings = this.noteContext!.viewScope!.tocCollapsedHeadings as Set<string>;
const validHeadingKeys = new Set<string>(); // Used to clean up obsolete entries in tocCollapsedHeadings
let headingCount = 0; let headingCount = 0;
for (let m = null, headingIndex = 0; (m = headingTagsRegex.exec(html)) !== null; headingIndex++) { for (let m = null, headingIndex = 0; (m = headingTagsRegex.exec(html)) !== null; headingIndex++) {
// //
@ -244,6 +297,11 @@ export default class TocWidget extends RightPanelWidget {
const $ol = $("<ol>"); const $ol = $("<ol>");
$ols[$ols.length - 1].append($ol); $ols[$ols.length - 1].append($ol);
$ols.push($ol); $ols.push($ol);
if ($previousLi) {
const headingKey = `h${newLevel}_${headingIndex}_${$previousLi?.text().trim()}`;
this.setupCollapsibleHeading($ol, $previousLi, headingKey, tocCollapsedHeadings, validHeadingKeys);
}
} }
} else if (levelDelta < 0) { } else if (levelDelta < 0) {
// Close as many lists as curLevel - newLevel // Close as many lists as curLevel - newLevel
@ -259,10 +317,20 @@ export default class TocWidget extends RightPanelWidget {
// //
const headingText = await this.replaceMathTextWithKatax(m[2]); const headingText = await this.replaceMathTextWithKatax(m[2]);
const $li = $("<li>").html(headingText); const $itemContent = $('<div class="item-content">').html(headingText).on("click", () => {
$li.on("click", () => this.jumpToHeading(headingIndex)); this.jumpToHeading(headingIndex);
});
const $li = $("<li>").append($itemContent);
$ols[$ols.length - 1].append($li); $ols[$ols.length - 1].append($li);
headingCount = headingIndex; headingCount = headingIndex;
$previousLi = $li;
}
// Clean up unused entries in tocCollapsedHeadings
for (const key of tocCollapsedHeadings) {
if (!validHeadingKeys.has(key)) {
tocCollapsedHeadings.delete(key);
}
} }
$toc = this.pullLeft($toc); $toc = this.pullLeft($toc);
@ -286,7 +354,7 @@ export default class TocWidget extends RightPanelWidget {
const $first = $toc.children(":first"); const $first = $toc.children(":first");
if ($first[0].tagName !== "OL") { if ($first[0].tagName.toLowerCase() !== "ol") {
break; break;
} }
@ -320,6 +388,59 @@ export default class TocWidget extends RightPanelWidget {
headingElement?.scrollIntoView({ behavior: "smooth" }); headingElement?.scrollIntoView({ behavior: "smooth" });
} }
async setupCollapsibleHeading($ol: JQuery<HTMLElement>, $previousLi: JQuery<HTMLElement>, headingKey: string, tocCollapsedHeadings: Set<string>, validHeadingKeys: Set<string>) {
if ($previousLi && $previousLi.find(".collapse-button").length === 0) {
const $collapseButton = $('<div class="collapse-button bx bx-chevron-down"></div>');
$previousLi.prepend($collapseButton);
// Restore the previous collapsed state
if (tocCollapsedHeadings?.has(headingKey)) {
$previousLi.addClass("collapsed");
validHeadingKeys.add(headingKey);
} else {
$previousLi.removeClass("collapsed");
}
$collapseButton.on("click", () => {
if ($previousLi.hasClass("animating")) return;
const willCollapse = !$previousLi.hasClass("collapsed");
$previousLi.addClass("animating");
if (willCollapse) { // Collapse
$ol.css("maxHeight", `${$ol.prop("scrollHeight")}px`);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
$ol.css("maxHeight", "0px");
$collapseButton.css("transform", "rotate(-90deg)");
});
});
setTimeout(() => {
$ol.css("maxHeight", "");
$previousLi.addClass("collapsed");
$previousLi.removeClass("animating");
}, 300);
} else { // Expand
$previousLi.removeClass("collapsed");
$ol.css("maxHeight", "0px");
requestAnimationFrame(() => {
$ol.css("maxHeight", `${$ol.prop("scrollHeight")}px`);
$collapseButton.css("transform", "");
});
setTimeout(() => {
$ol.css("maxHeight", "");
$previousLi.removeClass("animating");
}, 300);
}
if (willCollapse) { // Store collapsed headings
tocCollapsedHeadings!.add(headingKey);
} else {
tocCollapsedHeadings!.delete(headingKey);
}
});
}
}
async closeTocCommand() { async closeTocCommand() {
if (this.noteContext?.viewScope) { if (this.noteContext?.viewScope) {
this.noteContext.viewScope.tocTemporarilyHidden = true; this.noteContext.viewScope.tocTemporarilyHidden = true;