feat: implement TOC drawer feature (#657)

* feat: add toc drawer button

* feat: add TOC dialog functionality and related styles

* feat: change back-to-top button with scroll progress indicator

* feat: update color-scheme handling in theme switcher

* fix: code review for checking mistakes and optimization

* fix: the issue where the TOC was still clickable even when it was hidden.
This commit is contained in:
Cell
2025-11-18 12:35:34 +08:00
committed by GitHub
parent 44d68d9a5b
commit aad75044e9
9 changed files with 208 additions and 28 deletions
+4
View File
@@ -63,6 +63,10 @@ img,video {
height: auto;
}
dialog {
padding: 0;
}
details {
&.center {
summary {
+6
View File
@@ -40,6 +40,12 @@
}
}
@media only screen and (min-width: 961px) {
#toc-drawer-button {
display: none;
}
}
@media only screen and (max-width: 960px) {
%page-style {
width: ROUND(80%, 2px) !important;
+71 -2
View File
@@ -218,14 +218,14 @@
&::before {
color: $single-link-hover-color;
[data-theme='dark'] & {
color: $single-link-hover-color-dark;
}
}
}
}
> ol {
margin: 0;
padding: 0.4em 1em 0.4em 2.25em;
@@ -252,3 +252,72 @@
}
}
}
#toc-dialog {
transition:
display 0.2s allow-discrete,
overlay 0.2s allow-discrete,
translate 0.2s,
opacity 0.2s 0.4s;
opacity: 0;
translate: 100vw 0;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
margin-left: 4rem;
border: none;
.toc {
max-width: 100%;
margin-inline: 1rem;
.toc-title {
font-size: $toc-title-font-size * 1.5;
line-height: $toc-title-font-size * 2;
margin-block: $toc-title-font-size;
}
.toc-content {
font-size: max($toc-content-font-size, 1rem);
}
}
&::backdrop {
transition:
display 0.5s allow-discrete,
overlay 0.5s allow-discrete,
opacity 0.2s 0.4s;
opacity: 0;
}
&[open],
&[open]::backdrop {
opacity: 1;
transition:
display 0.2s allow-discrete,
overlay 0.2s allow-discrete,
translate 0.2s,
opacity 0.2s;
}
&[open] {
translate: 0 0;
}
@starting-style {
&[open],
&[open]::backdrop {
opacity: 0;
}
&[open] {
translate: 100vw 0;
}
}
}
html:has(#toc-dialog[open]) {
overflow: hidden;
}
@@ -1,12 +1,19 @@
.fixed-button {
.fixed-buttons {
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
@include z-index(1);
}
.fixed-button {
font-size: 1rem;
line-height: 1.5rem;
padding: 0.5rem 0.625rem;
color: $global-font-secondary-color;
color: $global-font-color;
background-color: $header-background-color;
@include z-index(1);
@include border-radius(1.25rem);
@include transition(color 0.4s ease);
@include blur;
@@ -25,7 +32,7 @@
}
[data-theme='dark'] & {
color: $global-font-secondary-color-dark;
color: $global-font-color-dark;
background-color: $header-background-color-dark;
&:hover,
@@ -36,9 +43,47 @@
}
.back-to-top {
bottom: 1.5rem;
}
overflow: hidden;
position: relative;
// Ring stroke width
--b2t-stroke: 0.5rem;
// Ring background color
--b2t-bg: #{$global-border-color};
// Progress color
--b2t-prog: #FF7359;
// 2π×50 ≈ 314 (radius 50 for viewBox 100)
--b2t-circumference: 314;
.view-comments {
bottom: 4.5rem;
[data-theme='dark'] & {
--b2t-bg: #{$global-border-color-dark};
--b2t-prog: #{$global-font-color-dark};
}
// SVG circular progress ring
svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
// Rotate to start from top
transform: rotate(-90deg);
}
.bg {
fill: none;
stroke: var(--b2t-bg);
stroke-width: var(--b2t-stroke);
}
// Progress ring
.progress {
fill: none;
stroke: var(--b2t-prog);
stroke-width: var(--b2t-stroke);
stroke-linecap: round;
stroke-dasharray: var(--b2t-circumference);
stroke-dashoffset: calc(var(--b2t-circumference) - var(--b2t-circumference) * var(--scroll-percent, 0) / 100);
transition: stroke-dashoffset .1s linear;
}
}
+1
View File
@@ -18,4 +18,5 @@ import params from '@params';
params.defaultTheme === 'dark';
}
isDark && (document.documentElement.dataset.theme = 'dark');
document.documentElement.style.setProperty('color-scheme', isDark ? 'dark' : 'light');
})();
+42 -13
View File
@@ -91,6 +91,7 @@ class FixIt {
this.util.forEach(document.getElementsByClassName('theme-switch'), ($themeSwitch) => {
$themeSwitch.addEventListener('click', () => {
document.documentElement.dataset.theme = this.isDark ? 'light' : 'dark';
document.documentElement.style.setProperty('color-scheme', this.isDark ? 'light' : 'dark');
this.isDark = !this.isDark;
window.localStorage?.setItem('theme', this.isDark ? 'dark' : 'light');
for (let event of this.switchThemeEventSet) {
@@ -650,12 +651,40 @@ class FixIt {
const animation = ['animate__faster'];
const tocHidden = $toc.classList.contains('toc-hidden');
animation.push(tocHidden ? 'animate__fadeIn' : 'animate__fadeOut');
$tocContentAuto.classList.remove(tocHidden ? 'animate__fadeOut' : 'animate__fadeIn');
this.util.animateCSS($tocContentAuto, animation, true);
if (tocHidden) {
$tocContentAuto.classList.remove('d-none', 'animate__fadeOut');
} else {
$tocContentAuto.classList.remove('animate__fadeIn');
}
this.util.animateCSS($tocContentAuto, animation, true, () => {
$tocContentAuto.classList.contains('animate__fadeOut') && $tocContentAuto.classList.add('d-none');
});
$toc.classList.toggle('toc-hidden');
}, false);
}
initTocDialog() {
// HTMLDialogElement
const dialog = document.querySelector("#toc-dialog");
const openButton = document.querySelector("#toc-drawer-button");
if (!dialog || !openButton) {
return;
}
openButton.addEventListener("click", () => {
dialog.showModal();
document.activeElement?.blur();
});
dialog.addEventListener("click", (e) => {
dialog.close();
});
dialog.addEventListener("keydown", (e) => {
// ensure Escape key closes the dialog (for robustness)
if (e.key === "Escape" && dialog.open) {
dialog.close();
}
});
}
/**
* It's a dirty hack to fix the bug of APlayer and smoothScroll.
* see https://github.com/hugo-fixit/FixIt/issues/292
@@ -1134,6 +1163,7 @@ class FixIt {
this.fixTocScroll();
this.initToc();
this.initTocListener();
this.initTocDialog();
}
this.initPangu();
this.initMathJax();
@@ -1237,7 +1267,6 @@ class FixIt {
onScroll() {
const $headers = [];
const ACCURACY = 20;
const $fixedButtons = document.querySelector('.fixed-buttons');
const $backToTop = document.querySelector('.back-to-top');
const $readingProgressBar = document.querySelector('.reading-progress-bar');
if (document.body.dataset.headerDesktop === 'auto') {
@@ -1275,20 +1304,19 @@ class FixIt {
if ($readingProgressBar) {
$readingProgressBar.style.setProperty('--progress', `${scrollPercent.toFixed(2)}%`);
}
// whether to show fixed buttons
if ($fixedButtons) {
// whether to show back to top button
if ($backToTop) {
if (scrollPercent > 1) {
$fixedButtons.classList.remove('d-none', 'animate__fadeOut');
this.util.animateCSS($fixedButtons, ['animate__fadeIn'], true);
$backToTop.classList.remove('d-none', 'animate__fadeOut');
this.util.animateCSS($backToTop, ['animate__fadeIn'], true);
} else {
$fixedButtons.classList.remove('animate__fadeIn');
this.util.animateCSS($fixedButtons, ['animate__fadeOut'], true, () => {
$fixedButtons.classList.contains('animate__fadeOut') && $fixedButtons.classList.add('d-none');
$backToTop.classList.remove('animate__fadeIn');
this.util.animateCSS($backToTop, ['animate__fadeOut'], true, () => {
$backToTop.classList.contains('animate__fadeOut') && $backToTop.classList.add('d-none');
});
}
if ($backToTop) {
$backToTop.querySelector('span').innerText = `${Math.round(scrollPercent)}%`;
}
// [todo] Shares the scrollPercent variable with readingProgressBar
$backToTop.style.setProperty('--scroll-percent', scrollPercent.toFixed(2));
}
for (let event of this.scrollEventSet) {
event();
@@ -1372,6 +1400,7 @@ class FixIt {
this.fixTocScroll();
this.initToc();
this.initTocListener();
this.initTocDialog();
}
this.onScroll();
this.onResize();
+1 -1
View File
@@ -988,7 +988,7 @@ style = "" # ["", "mp", "identicon", "monsterid", "wavatar", "retro", "blank", "
# FixIt 0.2.16 | NEW Back to top
[params.backToTop]
enable = true
# Scroll percent label in b2t button
# FixIt 0.4.0 | CHANGED whether to show the scroll percent indicator around the back to top button
scrollpercent = false
# FixIt 0.2.16 | NEW Reading progress bar
+15 -3
View File
@@ -1,13 +1,25 @@
<div class="widgets">
{{- $backToTop := .Site.Params.backToTop -}}
{{- $params := partial "function/params.html" -}}
{{- $comment := .Store.Get "comment" -}}
{{- if $backToTop.enable | or $comment.enable -}}
<div class="fixed-buttons animate__faster d-none">
<div class="fixed-buttons">
{{- /* back to top button */ -}}
{{- if $backToTop.enable -}}
<div class="fixed-button back-to-top" role="button" aria-label="{{ T `baseof.backToTop` }}">
<div class="fixed-button back-to-top animate__faster" role="button" aria-label="{{ T `baseof.backToTop` }}">
{{- dict "Class" "fa-solid fa-arrow-up fa-fw" | partial "plugin/icon.html" -}}
<span class="variant-numeric{{ if not $backToTop.scrollpercent }} d-none{{ end }}">0%</span>
{{- if $backToTop.scrollpercent -}}
<svg viewBox="0 0 100 100">
<circle class="bg" cx="50" cy="50" r="50"></circle>
<circle class="progress" cx="50" cy="50" r="50"></circle>
</svg>
{{- end -}}
</div>
{{- end -}}
{{- /* toc dialog button */ -}}
{{- if .Page.Store.Get "enableTocDialog" -}}
<div id="toc-drawer-button" class="fixed-button toc-drawer-button{{ with $params.password }} encrypted-hidden{{ end }}" role="button" aria-label="{{ T `single.contents` }}">
{{- dict "Class" "fa-solid fa-bars fa-fw" | partial "plugin/icon.html" -}}
</div>
{{- end -}}
{{- /* comment button */ -}}
+15 -1
View File
@@ -9,6 +9,7 @@
{{- $toc := .Store.Get "toc" -}}
{{- $tableOfContents := .Fragments.ToHTML ($toc.startlevel | int) ($toc.endlevel | int) ($toc.ordered | default false) -}}
{{- $showToc := $toc.enable | and (ne $tableOfContents `<nav id="TableOfContents"></nav>`) -}}
{{- $tableOfContents = dict "Content" $tableOfContents "Ruby" $params.ruby "Fraction" $params.fraction "Fontawesome" $params.fontawesome | partial "function/content.html" | safeHTML -}}
<aside class="aside-collection animate__animated animate__fadeIn animate__faster" aria-label="{{ T "collections" }}">
{{- /* Collection List */ -}}
@@ -162,7 +163,7 @@
<span>{{ dict "Class" "details-icon fa-solid fa-angle-right" | partial "plugin/icon.html" }}</span>
</div>
<div class="details-content toc-content" id="toc-content-static">
{{- dict "Content" $tableOfContents "Ruby" $params.ruby "Fraction" $params.fraction "Fontawesome" $params.fontawesome | partial "function/content.html" | safeHTML -}}
{{- $tableOfContents -}}
</div>
</div>
{{- end -}}
@@ -232,4 +233,17 @@
{{- /* Custom block after post toc */ -}}
{{- block "custom-post__toc:after" . }}{{ end -}}
</aside>
{{- /* TOC Dialog */ -}}
{{- if $showToc -}}
{{- .Page.Store.Set "enableTocDialog" true -}}
<dialog id="toc-dialog" aria-labelledby="toc-dialog-title" role="dialog">
<div class="toc">
<h2 class="toc-title">{{ T "single.contents" }}</h2>
<div class="toc-content" id="toc-content-drawer">
{{- replace $tableOfContents `id="TableOfContents"` "" | safeHTML -}}
</div>
</div>
</dialog>
{{- end -}}
{{- end -}}