Skip to content

Commit dac73a7

Browse files
author
nirajacharya2
authored
adding collapsible table of content (#118)
Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> fix(TOC): add name option, rename class to toc--item for sidebar styling
1 parent 411033d commit dac73a7

3 files changed

Lines changed: 143 additions & 30 deletions

File tree

‎src/components/detail/ContentSidebar.vue‎

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,7 @@
22
<div class="blog-content-sidebar">
33
<div class="toc">
44
<div class="title sharp-border">Table of content</div>
5-
<div v-for="(item, index) in toc"
6-
:key="index"
7-
class="toc--item"
8-
@click="scrollToHeading(item.id)"
9-
>
10-
<mdi-menu-right class="mdi-circle" />
11-
{{ item.text }}
12-
</div>
5+
<TOC :toc="toc" />
136
</div>
147
<br>
158
<div class="title sharp-border">Tags</div>
@@ -28,6 +21,7 @@
2821
</template>
2922
<script setup>
3023
import { defineProps } from "vue"
24+
import TOC from "./TOC.vue"
3125
3226
defineProps({
3327
toc: {
@@ -42,25 +36,6 @@ defineProps({
4236
}
4337
})
4438
45-
const scrollToHeading = (headingId) => {
46-
const xpath = `//*[@id="${headingId}"]`
47-
const heading = document.evaluate(
48-
xpath,
49-
document,
50-
null,
51-
XPathResult.FIRST_ORDERED_NODE_TYPE,
52-
null
53-
).singleNodeValue
54-
if (heading) {
55-
const position = heading.getBoundingClientRect()
56-
// scrolls to 100px above element
57-
window.scrollTo({
58-
left: position.left,
59-
top: position.top + window.scrollY - 100,
60-
behavior: "smooth"
61-
})
62-
}
63-
}
6439
</script>
6540
<style lang="scss">
6641
.blog-content-sidebar {

‎src/components/detail/TOC.vue‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<template>
2+
<div>
3+
<div v-for="item in toc" :key="item.id" :style="{ paddingLeft: item.val * 0.2 + 'em' }">
4+
5+
<div class="toc--item toc-content">
6+
<button v-if="item.node && item.node.length" type="button" class="toc-toggle"
7+
:aria-expanded="!collapsed.has(item.id)" :aria-controls="`toc-children-${item.id}`"
8+
:aria-label="collapsed.has(item.id) ? 'Expand section' : 'Collapse section'" @click.stop="toggle(item.id)"
9+
@keydown.enter.prevent.stop="toggle(item.id)" @keydown.space.prevent.stop="toggle(item.id)">
10+
<mdi-menu-right v-if="collapsed.has(item.id)" class="mdi-circle" />
11+
<mdi-menu-down v-else class="mdi-circle" />
12+
</button>
13+
<mdi-menu-right v-else class="mdi-circle" />
14+
15+
<button type="button" class="toc-link" @click="scrollToHeading(item.id, $event)"
16+
@keydown.enter.prevent="scrollToHeading(item.id, $event)"
17+
@keydown.space.prevent="scrollToHeading(item.id, $event)">
18+
<span class="toc-text">{{ item.title }}</span>
19+
</button>
20+
</div>
21+
22+
<Transition v-if="item.node && item.node.length" name="collapse">
23+
<div :id="`toc-children-${item.id}`" v-show="!collapsed.has(item.id)">
24+
<TOC :toc="item.node" />
25+
</div>
26+
</Transition>
27+
28+
</div>
29+
</div>
30+
</template>
31+
32+
<script setup>
33+
import { defineProps, ref } from "vue"
34+
35+
defineProps({
36+
toc: {
37+
type: Array,
38+
default: () => []
39+
}
40+
})
41+
42+
const collapsed = ref(new Set())
43+
44+
const toggle = (id) => {
45+
if (collapsed.value.has(id)) {
46+
collapsed.value.delete(id)
47+
} else {
48+
collapsed.value.add(id)
49+
}
50+
}
51+
52+
const scrollToHeading = (headingId, e) => {
53+
e.stopPropagation()
54+
55+
const heading = document.getElementById(headingId)
56+
if (!heading) return
57+
58+
const position = heading.getBoundingClientRect()
59+
60+
window.scrollTo({
61+
top: position.top + window.scrollY - 100,
62+
behavior: "smooth"
63+
})
64+
}
65+
</script>
66+
<script>
67+
export default { name: "TOC" }
68+
</script>
69+
<style scoped>
70+
.toc-content {
71+
display: flex;
72+
align-items: center;
73+
gap: 6px;
74+
padding-bottom: 0 !important;
75+
}
76+
77+
.toc-toggle, .toc-link {
78+
background: none;
79+
border: 0;
80+
padding: 0;
81+
margin: 0;
82+
color: inherit;
83+
font: inherit;
84+
cursor: pointer;
85+
}
86+
87+
.mdi-circle {
88+
display: inline-flex;
89+
align-items: center;
90+
justify-content: center;
91+
flex-shrink: 0;
92+
}
93+
94+
.toc-link {
95+
text-align: left;
96+
}
97+
98+
.collapse-enter-active, .collapse-leave-active {
99+
transition: all 0.25s ease;
100+
overflow: hidden;
101+
}
102+
103+
.collapse-enter-from, .collapse-leave-to {
104+
opacity: 0;
105+
transform: translateY(-4px);
106+
}
107+
</style>

‎src/helpers/markdown.js‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,43 @@ export const getTableOfContent = (source) => {
8484
const tokens = marked.lexer(source)
8585
// remove the first 2 tokens to avoid meta information
8686
// and then return every tokens that is of header type
87-
const headers = tokens.slice(2).filter(token => token.type === "heading" && token.depth <= 4)
88-
headers.forEach(header => {
87+
const headingGraphs = []
88+
const headers = tokens
89+
.slice(2)
90+
.filter((token) => token.type === "heading" && token.depth <= 4)
91+
headers.forEach((header) => {
8992
const html = marked.parser([header])
9093
const doc = new DOMParser().parseFromString(html, "text/xml")
9194
header.id = doc.firstChild.id
9295
header.text = doc.firstChild.textContent
96+
headingGraphs.push(new Graph(header.depth, header.text, header.id))
9397
})
94-
return headers
98+
if (headingGraphs.length === 0) return []
99+
const stack = []
100+
const finalHeadingGraph = []
101+
headingGraphs.forEach((headingGraph) => {
102+
while (stack.length && headingGraph.val <= stack[stack.length - 1].val) {
103+
stack.pop()
104+
}
105+
if (stack.length === 0) {
106+
finalHeadingGraph.push(headingGraph)
107+
} else {
108+
stack[stack.length - 1].addNode(headingGraph)
109+
}
110+
stack.push(headingGraph)
111+
})
112+
return finalHeadingGraph
113+
}
114+
115+
class Graph {
116+
constructor (val, title, id) {
117+
this.val = val
118+
this.title = title
119+
this.id = id
120+
this.node = []
121+
}
122+
123+
addNode (node) {
124+
this.node.push(node)
125+
}
95126
}

0 commit comments

Comments
 (0)