From c80d7a3ec37ab5c964cf46cd29acef8bd8b04f28 Mon Sep 17 00:00:00 2001 From: SiriusXT <1160925501@qq.com> Date: Fri, 16 May 2025 21:25:10 +0800 Subject: [PATCH] feat(toc): Collapsible TOC --- apps/client/src/services/link.ts | 1 + .../src/widgets/ribbon_widgets/note_map.ts | 2 +- apps/client/src/widgets/toc.ts | 131 +++++++++++++++++- 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts index a0d464741..3dac7688f 100644 --- a/apps/client/src/services/link.ts +++ b/apps/client/src/services/link.ts @@ -58,6 +58,7 @@ export interface ViewScope { * toc will appear and then close immediately, because getToc(html) function will consume time */ tocPreviousVisible?: boolean; + tocCollapsedHeadings?: Set; } interface CreateLinkOptions { diff --git a/apps/client/src/widgets/ribbon_widgets/note_map.ts b/apps/client/src/widgets/ribbon_widgets/note_map.ts index b44cbc8f9..b47de7b95 100644 --- a/apps/client/src/widgets/ribbon_widgets/note_map.ts +++ b/apps/client/src/widgets/ribbon_widgets/note_map.ts @@ -13,7 +13,7 @@ const TPL = /*html*/` height: 300px; } - .open-full-button, .collapse-button { + .note-map-ribbon-widget .open-full-button, .note-map-ribbon-widget .collapse-button { position: absolute; right: 5px; bottom: 5px; diff --git a/apps/client/src/widgets/toc.ts b/apps/client/src/widgets/toc.ts index 5c9a4b3e8..f88c3549f 100644 --- a/apps/client/src/widgets/toc.ts +++ b/apps/client/src/widgets/toc.ts @@ -29,23 +29,68 @@ const TPL = /*html*/`
contain: none; overflow: auto; position: relative; + padding-left:0px !important; } .toc ol { - padding-left: 25px; + position: relative; + overflow: hidden; + padding-left: 20px; + transition: max-height 0.3s ease; } .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 { + display: flex; + position: relative; + list-style: none; + align-items: center; + padding-left: 7px; cursor: pointer; text-align: justify; word-wrap: break-word; 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 { 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 let curLevel = 2; const $ols = [$toc]; + let $previousLi: JQuery | undefined; + + if (!(this.noteContext?.viewScope?.tocCollapsedHeadings instanceof Set)) { + this.noteContext!.viewScope!.tocCollapsedHeadings = new Set(); + } + const tocCollapsedHeadings = this.noteContext!.viewScope!.tocCollapsedHeadings as Set; + const validHeadingKeys = new Set(); // Used to clean up obsolete entries in tocCollapsedHeadings + let headingCount = 0; for (let m = null, headingIndex = 0; (m = headingTagsRegex.exec(html)) !== null; headingIndex++) { // @@ -244,6 +297,11 @@ export default class TocWidget extends RightPanelWidget { const $ol = $("
    "); $ols[$ols.length - 1].append($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) { // 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 $li = $("
  1. ").html(headingText); - $li.on("click", () => this.jumpToHeading(headingIndex)); + const $itemContent = $('
    ').html(headingText).on("click", () => { + this.jumpToHeading(headingIndex); + }); + const $li = $("
  2. ").append($itemContent); $ols[$ols.length - 1].append($li); 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); @@ -286,7 +354,7 @@ export default class TocWidget extends RightPanelWidget { const $first = $toc.children(":first"); - if ($first[0].tagName !== "OL") { + if ($first[0].tagName.toLowerCase() !== "ol") { break; } @@ -320,6 +388,59 @@ export default class TocWidget extends RightPanelWidget { headingElement?.scrollIntoView({ behavior: "smooth" }); } + async setupCollapsibleHeading($ol: JQuery, $previousLi: JQuery, headingKey: string, tocCollapsedHeadings: Set, validHeadingKeys: Set) { + if ($previousLi && $previousLi.find(".collapse-button").length === 0) { + const $collapseButton = $('
    '); + $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() { if (this.noteContext?.viewScope) { this.noteContext.viewScope.tocTemporarilyHidden = true;