feat: 编辑器支持返回目录数据,并实现目录锚点
This commit is contained in:
parent
d336938e78
commit
700f6980b9
19
src/App.vue
19
src/App.vue
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
36
src/components/UI/AnchorList.vue
Normal file
36
src/components/UI/AnchorList.vue
Normal 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>
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user