feat: 编辑器支持返回目录数据,并实现目录锚点
This commit is contained in:
parent
d336938e78
commit
700f6980b9
47
src/App.vue
47
src/App.vue
@ -1,23 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<Split v-model="split" min="5px">
|
||||||
<Split v-model="split">
|
<template #left>
|
||||||
<template #left>
|
<div class="split-left" :class="hiddenSplitLeft">
|
||||||
<div class="split-left" :class="hiddenSplitLeft">
|
<LeftSidebar v-model:rootFolderPath="rootFolderPath" v-model:currentFilePath="currentFilePath">
|
||||||
<LeftSidebar v-model:rootFolderPath="rootFolderPath" v-model:currentFilePath="currentFilePath">
|
</LeftSidebar>
|
||||||
</LeftSidebar>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
<template #trigger>
|
||||||
<template #trigger>
|
<div ref="splitTrigger" class="split-trigger"></div>
|
||||||
<div class="split-trigger"></div>
|
</template>
|
||||||
</template>
|
<template #right>
|
||||||
<template #right>
|
<div ref="splitRight" class="split-right">
|
||||||
<div ref="splitRight" class="split-right">
|
<MainEditor ref="mainEditor" v-model:markdownCode="markdownCode" :width="splitRightWidth"
|
||||||
<MainEditor ref="mainEditor" v-model:markdownCode="markdownCode" :save="writeFileContent">
|
:save="writeFileContent">
|
||||||
</MainEditor>
|
</MainEditor>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Split>
|
</Split>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@ -38,12 +37,14 @@ async function greet() {
|
|||||||
|
|
||||||
// 使用 Split 组件动态控制页面左右布局
|
// 使用 Split 组件动态控制页面左右布局
|
||||||
const split = ref(0.2);
|
const split = ref(0.2);
|
||||||
|
const splitTrigger = useTemplateRef('splitTrigger');
|
||||||
const splitRight = useTemplateRef('splitRight');
|
const splitRight = useTemplateRef('splitRight');
|
||||||
|
const splitRightWidth = ref(0);
|
||||||
const hiddenSplitLeft = ref("");
|
const hiddenSplitLeft = ref("");
|
||||||
watch(split, (newSplit) => {
|
watch(split, (newSplit) => {
|
||||||
|
splitRightWidth.value = splitRight.value.offsetWidth;
|
||||||
// 当 split 小于某个值时,隐藏左边布局
|
// 当 split 小于某个值时,隐藏左边布局
|
||||||
if (newSplit < 0.05) {
|
if (newSplit < 0.05) {
|
||||||
split.value = 0.01
|
|
||||||
if (hiddenSplitLeft.value == "") {
|
if (hiddenSplitLeft.value == "") {
|
||||||
hiddenSplitLeft.value = "hidden"
|
hiddenSplitLeft.value = "hidden"
|
||||||
}
|
}
|
||||||
@ -103,6 +104,8 @@ async function writeFileContent(markdownCode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// splitRight 的宽度为视口宽度右边百分比,再减去 splitTrigger 的宽度
|
||||||
|
splitRightWidth.value = window.innerWidth * (1 - split.value) - splitTrigger.value.offsetWidth;
|
||||||
// 监听页面宽度和高度,调整 markdown 编辑器的高宽度
|
// 监听页面宽度和高度,调整 markdown 编辑器的高宽度
|
||||||
window.addEventListener('resize', mainEditor.value.resizeEditorWindow);
|
window.addEventListener('resize', mainEditor.value.resizeEditorWindow);
|
||||||
// 打开上一次选择的文件的内容
|
// 打开上一次选择的文件的内容
|
||||||
@ -134,7 +137,8 @@ body {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
.split-trigger {
|
.split-trigger {
|
||||||
width: 5px;
|
width: 5px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@ -153,4 +157,3 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<style></style>
|
|
||||||
|
|||||||
@ -1,16 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<MarkdownEditor ref="markdownRef" width="100%" height="100%" :appendToolbar="appendToolbar"
|
<Split v-model="split">
|
||||||
:autoInit="false" :markdownCode="markdownCode" @update:markdownCode="updateMarkdownCode"
|
<template #left>
|
||||||
:imageUpload="true" :imageUploadURL="imageUploadURLOfEditor" :imageUploadURLChange="changeImageUploadURL"
|
<MarkdownEditor ref="markdownRef" width="100%" height="100%" :appendToolbar="appendToolbar"
|
||||||
:onload="reloadEditorHeight" :onFullScreenExit="resizeEditorWindow" />
|
:autoInit="false" :markdownCode="markdownCode" @update:markdownCode="updateMarkdownCode"
|
||||||
|
: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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import MarkdownEditor from './UI/MarkdownEditor.vue';
|
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'
|
import { Message } from 'view-ui-plus'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
// 由父组件传递告诉当前组件的持有宽度多大
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
required: false,
|
||||||
|
default: window.innerWidth
|
||||||
|
},
|
||||||
markdownCode: {
|
markdownCode: {
|
||||||
type: String,
|
type: String,
|
||||||
required: false,
|
required: false,
|
||||||
@ -29,6 +47,19 @@ const emit = defineEmits([
|
|||||||
'update:markdownCode',
|
'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 编辑器
|
// 加载 Markdown 编辑器
|
||||||
const markdownRef = ref(null);
|
const markdownRef = ref(null);
|
||||||
const initMarkdownEditor = () => {
|
const initMarkdownEditor = () => {
|
||||||
@ -42,6 +73,7 @@ const setMarkdownCode = (newMarkdownCode) => {
|
|||||||
}
|
}
|
||||||
const updateMarkdownCode = (newMarkdownCode) => {
|
const updateMarkdownCode = (newMarkdownCode) => {
|
||||||
emit('update:markdownCode', newMarkdownCode); // 触发双向绑定更新
|
emit('update:markdownCode', newMarkdownCode); // 触发双向绑定更新
|
||||||
|
tocNodes.value = markdownRef.value.getTableOfContents(); // 更新目录
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支持从 localStorage 读写 imageUploadURL
|
// 支持从 localStorage 读写 imageUploadURL
|
||||||
@ -81,6 +113,20 @@ const appendToolbar = [
|
|||||||
},
|
},
|
||||||
nofocus: true,
|
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",
|
name: "publish",
|
||||||
icon: "fa-cloud-upload",
|
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 -->
|
<!-- rewrite Markdown CSS(Cmd Markdown Style) from https://github.com/wakaryry/editorMd -->
|
||||||
<link rel="stylesheet" href="/static/CmdMarkdown/custom.css">
|
<link rel="stylesheet" href="/static/CmdMarkdown/custom.css">
|
||||||
|
|
||||||
<div id="markdown-editor"></div>
|
<div :id="editorID"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -15,6 +15,11 @@ let editorClient = defaultEditorValue;
|
|||||||
export default {
|
export default {
|
||||||
name: 'MarkdownEditor',
|
name: 'MarkdownEditor',
|
||||||
props: {
|
props: {
|
||||||
|
editorID: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
default: 'markdown-editor'
|
||||||
|
},
|
||||||
// 加载该组件时,是否自动执行 initEditor
|
// 加载该组件时,是否自动执行 initEditor
|
||||||
// 如果为 false,则将初始化的时机和主动权交给父组件
|
// 如果为 false,则将初始化的时机和主动权交给父组件
|
||||||
autoInit: {
|
autoInit: {
|
||||||
@ -106,6 +111,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loadingCompleted: false,
|
loadingCompleted: false,
|
||||||
|
editormdPreview: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@ -133,7 +139,7 @@ export default {
|
|||||||
// 加载完成以上 JS 资源之后,再加载编辑器
|
// 加载完成以上 JS 资源之后,再加载编辑器
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const vm = this
|
const vm = this
|
||||||
const editor = window.editormd('markdown-editor', {
|
const editor = window.editormd(vm.editorID, {
|
||||||
// 初始化内容
|
// 初始化内容
|
||||||
markdown: vm.markdownCode,
|
markdown: vm.markdownCode,
|
||||||
// 定义宽度和高度
|
// 定义宽度和高度
|
||||||
@ -190,6 +196,7 @@ export default {
|
|||||||
// 将 editor 变量赋予 editorClient,便于后续调用 API
|
// 将 editor 变量赋予 editorClient,便于后续调用 API
|
||||||
editorClient = this
|
editorClient = this
|
||||||
vm.loadingCompleted = true
|
vm.loadingCompleted = true
|
||||||
|
vm.editormdPreview = $('#' + vm.editorID + ' .editormd-preview-container')[0]
|
||||||
// 执行父组件传递的 callback 方法
|
// 执行父组件传递的 callback 方法
|
||||||
vm.onload()
|
vm.onload()
|
||||||
|
|
||||||
@ -249,8 +256,105 @@ export default {
|
|||||||
}
|
}
|
||||||
return editorClient.getPreviewedHTML()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
Loading…
x
Reference in New Issue
Block a user