Electron-vite【实战】MD 编辑器 -- 大纲区(含自动生成大纲,大纲缩进,折叠大纲,滚动同步高亮大纲,点击大纲滚动等)
最终效果
页面
<!-- 大纲 --><div v-if="currentFilePath && outlineList.length" class="outlinePanel"><div class="panelTitle">大纲</div><div class="searchTitleBox"><Icon class="searchTitleInputIcon" icon="material-symbols-light:search" /><inputv-model="searchTitleKeyWord"class="searchTitleInput"type="text"placeholder="请输入标题"/><Iconv-show="searchTitleKeyWord"class="clearSearchTitleInputBtn"icon="codex:cross"@click="clearSearchTitleInput"/></div><div class="outlineListBox"><template v-for="(item, index) in outlineList_filtered"><divv-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"><Iconv-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"><Iconv-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.contentoutlineList.push({...resultTemp.outline,index})}}
最终返回
return {content: result,outline: outlineList}
其中格式解析的方法 parseMarkdownHeadings 如下:
/*** 将 Markdown 标题(# 开头)转换为 HTML 标题标签* @param markdown - 输入的 Markdown 文本* @returns 转换后的 HTML 文本*/
function parseMarkdownHeadings(markdown: string,index: number
): {content: stringoutline: outlineItemType
} | void {// 正则表达式匹配 Markdown 标题const headingRegex = /^(#+)\s+(.*)$/gmconst match = headingRegex.exec(markdown)if (match) {const level = match[1].length // 标题等级由#的数量决定const text = match[2].trim()const id = index.toString() // 生成锚点IDreturn {content: `<h${level} id=${id} >${escapeHTML(text)}</h${level}>`,outline: {text,id,level}}}
}
切换大纲项的折叠状态
// 切换大纲项的折叠状态
const toggleCollapse = (item: outlineItemType, index: number): void => {item.collapsed = !item.collapsedfor (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.idconst editor = editorRef.valueif (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.valueif (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.valueconst preview = previewRef.valueconst editorScrollRatio = editor.scrollTop / (editor.scrollHeight - editor.clientHeight)const previewScrollTop = editorScrollRatio * (preview.scrollHeight - preview.clientHeight)preview.scrollTop = previewScrollTop}
}
滚动预览区时,当前高亮大纲同步变化
onMounted(() => {if (previewRef.value) {// 监听滚动事件以更新当前大纲项IDpreviewRef.value.addEventListener('scroll', update_currentOutlineId)}
// 更新当前高亮大纲
const update_currentOutlineId = (): void => {if (ifClickOutLine.value) {return}if (!previewRef.value) returnconst 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)}