最终效果
页面
<!-- 大纲 -->
<div v-if="currentFilePath && outlineList.length" class="outlinePanel">
<div class="panelTitle">大纲</div>
<div class="searchTitleBox">
<Icon class="searchTitleInputIcon" icon="material-symbols-light:search" />
<input
v-model="searchTitleKeyWord"
class="searchTitleInput"
type="text"
placeholder="请输入标题"
/>
<Icon
v-show="searchTitleKeyWord"
class="clearSearchTitleInputBtn"
icon="codex:cross"
@click="clearSearchTitleInput"
/>
</div>
<div class="outlineListBox">
<template v-for="(item, index) in outlineList_filtered">
<div
v-if="!item.hide"
:key="index"
:title="item.text"
class="outlineItem"
:style="{ color: currentOutLineID === item.id ? 'red' : '' }"
@click="handle_clickOutLine(item)"
>
<div v-for="i in item.level" :key="i">
<Icon
v-if="
index < outlineList_filtered.length - 1 &&
item.level < outlineList_filtered[index + 1].level &&
i === item.level
"
:icon="
item.collapsed ? 'iconamoon:arrow-right-2-light' : 'iconamoon:arrow-down-2-light'
"
@click.stop="toggleCollapse(item, index)"
/>
<Icon v-else icon="" />
</div>
<div style="margin-left: 4px">
{{ item.text }}
</div>
</div>
</template>
</div>
</div>
不同级别标题的缩进和折叠图标的实现
<div v-for="i in item.level" :key="i">
<Icon
v-if="
index < outlineList_filtered.length - 1 &&
item.level < outlineList_filtered[index + 1].level &&
i === item.level
"
:icon="
item.collapsed ? 'iconamoon:arrow-right-2-light' : 'iconamoon:arrow-down-2-light'
"
@click.stop="toggleCollapse(item, index)"
/>
<Icon v-else icon="" />
</div>
- 按 level 值,渲染空白图标, level 值越大,缩进越多。
- 仅当下一行标题的 level 更大,即有子标题时,显示大纲折叠图标
相关样式
/* 大纲的样式 */
.outlinePanel {
width: 200px;
border: 1px solid gray;
border-left: none;
display: flex;
flex-direction: column;
font-size: 14px;
}
.searchTitleBox {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
}
.searchTitleInput {
display: block;
font-size: 12px;
padding: 4px 20px;
}
.searchTitleInputIcon {
position: absolute;
font-size: 16px;
transform: translateX(-80px);
}
.clearSearchTitleInputBtn {
position: absolute;
cursor: pointer;
font-size: 16px;
transform: translateX(77px);
}
.outlineListBox {
padding: 10px;
line-height: 1.5;
flex: 1;
overflow-y: auto;
}
.outlineItem {
cursor: pointer;
text-wrap: nowrap;
display: flex;
align-items: center;
color: #787474;
margin-bottom: 6px;
}
相关变量
// 大纲相关变量
const searchTitleKeyWord = ref('') // 搜索大纲的关键词
const currentOutLineID = ref('0') // 当前选中的大纲项的id
const ifClickOutLine = ref(false) // 是否点击了大纲项
// 计算属性:根据markdownContent生成大纲列表
const outlineList = computed(() => {
const originalList = MarkdownParser(markdownContent.value).outline || []
// 使用 reactive 让大纲项的每个属性都是响应式
return originalList.map((item) => reactive({ ...item, collapsed: false }))
})
// 计算属性:根据searchTitleKeyWord过滤大纲列表
const outlineList_filtered = computed(() => {
let result = outlineList.value.filter((outline) => {
return outline.text.toLowerCase().includes(searchTitleKeyWord.value.toLowerCase())
})
return result
})
核心方法
根据 markdown 内容生成大纲
const originalList = MarkdownParser(markdownContent.value).outline || []
在 MarkdownParser 方法中,遍历每一行时,将标题行转换为目标格式,存入 outlineList 中
// 标题
if (line.startsWith('#')) {
const resultTemp = parseMarkdownHeadings(line, index)
if (resultTemp) {
line = resultTemp.content
outlineList.push({
...resultTemp.outline,
index
})
}
}
最终返回
return {
content: result,
outline: outlineList
}
其中格式解析的方法 parseMarkdownHeadings 如下:
/**
* 将 Markdown 标题(# 开头)转换为 HTML 标题标签
* @param markdown - 输入的 Markdown 文本
* @returns 转换后的 HTML 文本
*/
function parseMarkdownHeadings(
markdown: string,
index: number
): {
content: string
outline: outlineItemType
} | void {
// 正则表达式匹配 Markdown 标题
const headingRegex = /^(#+)\s+(.*)$/gm
const match = headingRegex.exec(markdown)
if (match) {
const level = match[1].length // 标题等级由#的数量决定
const text = match[2].trim()
const id = index.toString() // 生成锚点ID
return {
content: `<h${level} id=${id} >${escapeHTML(text)}</h${level}>`,
outline: {
text,
id,
level
}
}
}
}
切换大纲项的折叠状态
// 切换大纲项的折叠状态
const toggleCollapse = (item: outlineItemType, index: number): void => {
item.collapsed = !item.collapsed
for (let i = index + 1, len = outlineList_filtered.value.length; i < len; i++) {
const outline = outlineList_filtered.value[i]
if (outline.level > item.level) {
outline.hide = item.collapsed
} else {
break
}
}
}
点击大纲项滚动编辑区和预览区
@click="handle_clickOutLine(item)"
// 点击大纲项
const handle_clickOutLine = (item): void => {
currentOutLineID.value = item.id
const editor = editorRef.value
if (editor) {
ifClickOutLine.value = true
// 编辑区滚动滚动到指定行
const lineHeight = parseInt(getComputedStyle(editor).lineHeight)
const paddingTop = parseInt(getComputedStyle(editor).paddingTop)
editor.scrollTo({
top: paddingTop + item.index * lineHeight,
behavior: 'smooth'
})
}
const preview = previewRef.value
if (preview) {
const targetDom = document.getElementById(item.id)
if (targetDom) {
targetDom.scrollIntoView({
behavior: 'smooth',
block: 'start'
})
setTimeout(() => {
// 延迟1s后,将 ifClickOutLine 置为 false,防止点击大纲项时,在滚动编辑区时,同步滚动预览区
ifClickOutLine.value = false
}, 1000)
}
}
}
点击大纲项时,禁止编辑区的滚动导致预览区同步滚动
// 同步预览区滚动
const syncPreviewScroll = (): void => {
// 点击大纲项时,不触发同步预览区滚动
if (editorRef.value && previewRef.value && !ifClickOutLine.value) {
const editor = editorRef.value
const preview = previewRef.value
const editorScrollRatio = editor.scrollTop / (editor.scrollHeight - editor.clientHeight)
const previewScrollTop = editorScrollRatio * (preview.scrollHeight - preview.clientHeight)
preview.scrollTop = previewScrollTop
}
}
滚动预览区时,当前高亮大纲同步变化
onMounted(() => {
if (previewRef.value) {
// 监听滚动事件以更新当前大纲项ID
previewRef.value.addEventListener('scroll', update_currentOutlineId)
}
// 更新当前高亮大纲
const update_currentOutlineId = (): void => {
if (ifClickOutLine.value) {
return
}
if (!previewRef.value) return
const headings = previewRef.value.querySelectorAll('h1, h2, h3, h4, h5, h6')
let lastId = ''
for (const heading of headings) {
// 当标题即将超出预览区域时,停止查找更新
if ((heading as HTMLElement).offsetTop - 60 >= previewRef.value.scrollTop) {
break
}
lastId = heading.id
}
currentOutLineID.value = lastId
}
onBeforeUnmount 中
if (previewRef.value) {
previewRef.value.removeEventListener('click', handle_preview_click)
previewRef.value.removeEventListener('scroll', update_currentOutlineId)
}