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,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>

View File

@ -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",

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 --> <!-- 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>