feat: 编辑器支持返回目录数据,并实现目录锚点

This commit is contained in:
Frankie Huang 2025-04-26 02:05:04 +08:00
parent d336938e78
commit 700f6980b9
4 changed files with 218 additions and 29 deletions

View File

@ -1,6 +1,5 @@
<template>
<div>
<Split v-model="split">
<Split v-model="split" min="5px">
<template #left>
<div class="split-left" :class="hiddenSplitLeft">
<LeftSidebar v-model:rootFolderPath="rootFolderPath" v-model:currentFilePath="currentFilePath">
@ -8,16 +7,16 @@
</div>
</template>
<template #trigger>
<div class="split-trigger"></div>
<div ref="splitTrigger" class="split-trigger"></div>
</template>
<template #right>
<div ref="splitRight" class="split-right">
<MainEditor ref="mainEditor" v-model:markdownCode="markdownCode" :save="writeFileContent">
<MainEditor ref="mainEditor" v-model:markdownCode="markdownCode" :width="splitRightWidth"
:save="writeFileContent">
</MainEditor>
</div>
</template>
</Split>
</div>
</template>
<script setup>
@ -38,12 +37,14 @@ async function greet() {
// 使用 Split 组件动态控制页面左右布局
const split = ref(0.2);
const splitTrigger = useTemplateRef('splitTrigger');
const splitRight = useTemplateRef('splitRight');
const splitRightWidth = ref(0);
const hiddenSplitLeft = ref("");
watch(split, (newSplit) => {
splitRightWidth.value = splitRight.value.offsetWidth;
// 当 split 小于某个值时,隐藏左边布局
if (newSplit < 0.05) {
split.value = 0.01
if (hiddenSplitLeft.value == "") {
hiddenSplitLeft.value = "hidden"
}
@ -103,6 +104,8 @@ async function writeFileContent(markdownCode) {
}
}
onMounted(async () => {
// splitRight 的宽度为视口宽度右边百分比,再减去 splitTrigger 的宽度
splitRightWidth.value = window.innerWidth * (1 - split.value) - splitTrigger.value.offsetWidth;
// 监听页面宽度和高度,调整 markdown 编辑器的高宽度
window.addEventListener('resize', mainEditor.value.resizeEditorWindow);
// 打开上一次选择的文件的内容
@ -134,7 +137,8 @@ body {
overflow-y: auto;
scrollbar-width: none;
}
</style>
<style>
.split-trigger {
width: 5px;
height: 100vh;
@ -153,4 +157,3 @@ body {
display: none;
}
</style>
<style></style>

View File

@ -1,16 +1,34 @@
<template>
<Split v-model="split">
<template #left>
<MarkdownEditor ref="markdownRef" width="100%" height="100%" :appendToolbar="appendToolbar"
:autoInit="false" :markdownCode="markdownCode" @update:markdownCode="updateMarkdownCode"
:imageUpload="true" :imageUploadURL="imageUploadURLOfEditor" :imageUploadURLChange="changeImageUploadURL"
:onload="reloadEditorHeight" :onFullScreenExit="resizeEditorWindow" />
:imageUpload="true" :imageUploadURL="imageUploadURLOfEditor"
:imageUploadURLChange="changeImageUploadURL" :onload="reloadEditorHeight"
:onFullScreenExit="resizeEditorWindow" />
</template>
<template #trigger>
<div class="split-trigger"></div>
</template>
<template #right>
<AnchorList v-model:nodes="tocNodes"></AnchorList>
</template>
</Split>
</template>
<script setup>
import MarkdownEditor from './UI/MarkdownEditor.vue';
import { ref } from "vue";
import AnchorList from './UI/AnchorList.vue';
import { ref, watch } from "vue";
import { Message } from 'view-ui-plus'
const props = defineProps({
// 由父组件传递告诉当前组件的持有宽度多大
width: {
type: Number,
required: false,
default: window.innerWidth
},
markdownCode: {
type: String,
required: false,
@ -29,6 +47,19 @@ const emit = defineEmits([
'update:markdownCode',
]);
// 定义布局,主要用于在右边显示文章目录
const split = ref(1);
watch(split, (newSplit) => {
// 动态调整 MarkdownEditor 的宽度。如果不执行 reset 的话,CodeMirror 内部编辑器不会自适应宽度
// 编辑器的宽度不能简单地使用 splitLeft.value.offsetWidth 来赋值,因为该值可能有延迟更新的情况
// 1. 使用 (splitLeft.value.offsetWidth + splitRight.value.offsetWidth) * newSplit
// 2. 使用父组件传递进来的 width 值乘以 newSplit,来确定编辑器应有的宽度
markdownRef.value.resetWidth(props.width * newSplit);
})
// 初始化目录
const tocNodes = ref([])
// 加载 Markdown 编辑器
const markdownRef = ref(null);
const initMarkdownEditor = () => {
@ -42,6 +73,7 @@ const setMarkdownCode = (newMarkdownCode) => {
}
const updateMarkdownCode = (newMarkdownCode) => {
emit('update:markdownCode', newMarkdownCode); // 触发双向绑定更新
tocNodes.value = markdownRef.value.getTableOfContents(); // 更新目录
}
// 支持从 localStorage 读写 imageUploadURL
@ -81,6 +113,20 @@ const appendToolbar = [
},
nofocus: true,
},
{
name: "showTOC",
icon: "fa-list-alt",
title: "显示/隐藏目录",
handler: () => {
if (split.value == 1) {
tocNodes.value = markdownRef.value.getTableOfContents();
split.value = 0.8;
} else {
split.value = 1;
}
},
nofocus: true,
},
{
name: "publish",
icon: "fa-cloud-upload",

View File

@ -0,0 +1,36 @@
<template>
<Anchor v-if="nodes !== null" :affix="false">
<AnchorLink v-for="node in nodes" :key="node.name" :href="'#' + node.name" :title="node.name">
<AnchorList :nodes="node.nodes"></AnchorList>
</AnchorLink>
</Anchor>
</template>
<script setup>
defineProps({
/**
* 输出示例:
* [{
* "name": "1",
* "nodes": [{
* "name": "1.1",
* "nodes": []
* }]
* },
* {
* "name": "2",
* "nodes": [{
* "name": "2.1",
* "nodes": []
* }]
* }]
*/
nodes: {
type: Array,
required: false,
default: () => {
return null;
}
},
});
</script>

View File

@ -3,7 +3,7 @@
<!-- rewrite Markdown CSS(Cmd Markdown Style) from https://github.com/wakaryry/editorMd -->
<link rel="stylesheet" href="/static/CmdMarkdown/custom.css">
<div id="markdown-editor"></div>
<div :id="editorID"></div>
</template>
<script>
@ -15,6 +15,11 @@ let editorClient = defaultEditorValue;
export default {
name: 'MarkdownEditor',
props: {
editorID: {
type: String,
required: false,
default: 'markdown-editor'
},
// 加载该组件时,是否自动执行 initEditor
// 如果为 false,则将初始化的时机和主动权交给父组件
autoInit: {
@ -106,6 +111,7 @@ export default {
data() {
return {
loadingCompleted: false,
editormdPreview: null,
}
},
mounted() {
@ -133,7 +139,7 @@ export default {
// 加载完成以上 JS 资源之后,再加载编辑器
this.$nextTick(() => {
const vm = this
const editor = window.editormd('markdown-editor', {
const editor = window.editormd(vm.editorID, {
// 初始化内容
markdown: vm.markdownCode,
// 定义宽度和高度
@ -190,6 +196,7 @@ export default {
// 将 editor 变量赋予 editorClient,便于后续调用 API
editorClient = this
vm.loadingCompleted = true
vm.editormdPreview = $('#' + vm.editorID + ' .editormd-preview-container')[0]
// 执行父组件传递的 callback 方法
vm.onload()
@ -249,8 +256,105 @@ export default {
}
return editorClient.getPreviewedHTML()
},
getTableOfContents() {
return generateTableOfContents(this.editormdPreview);
},
}
}
/**
* 获取一篇文档的章节标题的目录
*
* 入参示例:
* <div>
* <h1>1</h1>
* <h2>1.1</h2>
* <h1>2</h1>
* <h2>2.1</h2>
* </div>
*
* 输出示例:
* [{
* "name": "1",
* "nodes": [{
* "name": "1.1",
* "nodes": []
* }]
* },
* {
* "name": "2",
* "nodes": [{
* "name": "2.1",
* "nodes": []
* }]
* }]
*
* @param {Element} element 文档的 document 对象
* @returns {Array} tocObject 目录列表(Table of Contents)
*/
function generateTableOfContents(element) {
// 该变量用于存储文章目录节点
let tableOfContents = {
name: "",
parent: null,
nodes: [],
};
// 遍历得到所有直接子元素中的 <h> 标签
const hTags = element.querySelectorAll(":scope > h1, :scope > h2, :scope > h3, :scope > h4, :scope > h5, :scope > h6");
let lastLevel = 0;
let lastNode = tableOfContents;
// 遍历文档内所有 H 标签
hTags.forEach(hTag => {
// currentLevel 表示 H 标签后的数字,比如 H1 标签,那么 currentLevel 为 1
let currentLevel = hTag.tagName[1];
// headTitle 则是 H 标签的文本,将用于在目录中显示
let headTitle = hTag.innerText;
lastNode = generateNodeOfTOC(lastNode, lastLevel, currentLevel, headTitle);
lastLevel = currentLevel;
});
// 删除 node 节点中的 parent 字段,避免循环嵌套(circular structure)问题
deleteParentAssociation(tableOfContents);
return tableOfContents.nodes;
}
function generateNodeOfTOC(lastNode, lastLevel, currentLevel, headTitle) {
// 需要找到刚好比当前节点大一级的 lastNode
if (currentLevel > lastLevel) {
// 如果当前 H 标题的级别小于上一个 H 标题(数字越小,级别越大)
let diff = currentLevel - lastLevel;
for (let i = 0; i < diff - 1; i++) {
let node = {
name: "",
nodes: [],
parent: lastNode,
}
lastNode.nodes.push(node);
lastNode = node;
}
} else {
// 如果当前 H 标题的级别大于等于上一个 H 标题(数字越小,级别越大)
let diff = lastLevel - currentLevel;
for (let i = 0; i <= diff; i++) {
lastNode = lastNode.parent;
}
}
let currentNode = {
name: headTitle,
nodes: [],
parent: lastNode,
}
lastNode.nodes.push(currentNode);
return currentNode;
}
function deleteParentAssociation(node) {
delete node.parent;
node.nodes.forEach(n => {
deleteParentAssociation(n);
})
}
</script>
<style scoped></style>