3535 <CommentSection v-if =" article ?.id " :article-id =" article .id " />
3636 </template >
3737 </div >
38+
39+ <!-- 外挂式目录:左侧抽屉 -->
40+ <button
41+ v-if =" tocItems.length"
42+ class =" toc-fab"
43+ type =" button"
44+ :aria-expanded =" tocOpen ? 'true' : 'false'"
45+ aria-controls =" article-toc-drawer"
46+ @click =" tocOpen = true"
47+ >
48+ 目录
49+ </button >
50+
51+ <div v-if =" tocOpen" class =" toc-overlay" @click =" tocOpen = false" />
52+ <aside
53+ id =" article-toc-drawer"
54+ class =" toc-drawer"
55+ :class =" { open: tocOpen }"
56+ role =" dialog"
57+ aria-label =" 文章目录"
58+ >
59+ <div class =" toc-drawer-head" >
60+ <div class =" toc-drawer-title" >目录</div >
61+ <button class =" toc-drawer-close" type =" button" aria-label =" 关闭目录" @click =" tocOpen = false" >
62+ ×
63+ </button >
64+ </div >
65+ <nav class =" toc-drawer-body" aria-label =" 目录导航" >
66+ <button
67+ v-for =" it in tocItems"
68+ :key =" it.id"
69+ class =" toc-item"
70+ type =" button"
71+ :style =" { paddingLeft: `${8 + Math.max(0, it.level - 1) * 12}px` }"
72+ @click =" jumpToHeading(it.id)"
73+ >
74+ {{ it.text }}
75+ </button >
76+ </nav >
77+ </aside >
3878 </div >
3979</template >
4080
4181<script setup>
42- import { computed , onMounted , ref } from ' vue'
82+ import { onBeforeUnmount , onMounted , ref , watchEffect } from ' vue'
4383import { useRoute } from ' vue-router'
4484import DOMPurify from ' dompurify'
4585import { marked } from ' marked'
@@ -50,6 +90,9 @@ import CommentSection from '../components/CommentSection.vue'
5090const route = useRoute ()
5191const loading = ref (true )
5292const article = ref ({})
93+ const html = ref (' ' )
94+ const tocItems = ref ([])
95+ const tocOpen = ref (false )
5396
5497onMounted (async () => {
5598 loading .value = true
@@ -61,20 +104,40 @@ onMounted(async () => {
61104 }
62105})
63106
64- const html = computed (() => {
65- const md = article .value .contentMarkdown || ' '
66- const raw = marked .parse (md)
67- return DOMPurify .sanitize (raw)
107+ watchEffect (() => {
108+ const md = article .value ? .contentMarkdown || ' '
109+ const items = []
110+
111+ const renderer = new marked.Renderer ()
112+ const slugger = new marked.Slugger ()
113+
114+ renderer .heading = (text , level , raw ) => {
115+ const id = slugger .slug (String (raw || text || ' ' ))
116+ const plain = String (text || ' ' )
117+ .replace (/ <[^ >] + >/ g , ' ' )
118+ .replace (/ \s + / g , ' ' )
119+ .trim ()
120+ if (plain) items .push ({ id, level, text: plain })
121+ return ` <h${ level} id="${ id} ">${ text} </h${ level} >`
122+ }
123+
124+ const rawHtml = marked .parse (md, { renderer })
125+ html .value = DOMPurify .sanitize (rawHtml)
126+ tocItems .value = items
127+
128+ if (! items .length ) tocOpen .value = false
68129})
69130
70131/** 后端若将来返回分类名则展示 */
71- const categoryLabel = computed (() => {
132+ const categoryLabel = ref (' ' )
133+ const readMinutes = ref (1 )
134+
135+ watchEffect (() => {
72136 const a = article .value
73- return a? .categoryName || a? .categoryLabel || ' '
137+ categoryLabel .value = a? .categoryName || a? .categoryLabel || ' '
138+ readMinutes .value = estimateReadMinutes (a? .contentMarkdown )
74139})
75140
76- const readMinutes = computed (() => estimateReadMinutes (article .value ? .contentMarkdown ))
77-
78141function estimateReadMinutes (md ) {
79142 if (! md || typeof md !== ' string' ) return 1
80143 const chars = md .replace (/ \s + / g , ' ' ).length
@@ -89,4 +152,20 @@ function formatDate(v) {
89152 return ' '
90153 }
91154}
155+
156+ function jumpToHeading (id ) {
157+ tocOpen .value = false
158+ requestAnimationFrame (() => {
159+ const el = document .getElementById (id)
160+ if (! el) return
161+ el .scrollIntoView ({ behavior: ' smooth' , block: ' start' })
162+ })
163+ }
164+
165+ function onKeyDown (e ) {
166+ if (e .key === ' Escape' && tocOpen .value ) tocOpen .value = false
167+ }
168+
169+ onMounted (() => window .addEventListener (' keydown' , onKeyDown))
170+ onBeforeUnmount (() => window .removeEventListener (' keydown' , onKeyDown))
92171< / script>
0 commit comments