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>
<div>
<Split v-model="split">
<template #left>
<div class="split-left" :class="hiddenSplitLeft">
<LeftSidebar v-model:rootFolderPath="rootFolderPath" v-model:currentFilePath="currentFilePath">
</LeftSidebar>
</div>
</template>
<template #trigger>
<div class="split-trigger"></div>
</template>
<template #right>
<div ref="splitRight" class="split-right">
<MainEditor ref="mainEditor" v-model:markdownCode="markdownCode" :save="writeFileContent">
</MainEditor>
</div>
</template>
</Split>
</div>
<Split v-model="split" min="5px">
<template #left>
<div class="split-left" :class="hiddenSplitLeft">
<LeftSidebar v-model:rootFolderPath="rootFolderPath" v-model:currentFilePath="currentFilePath">
</LeftSidebar>
</div>
</template>
<template #trigger>
<div ref="splitTrigger" class="split-trigger"></div>
</template>
<template #right>
<div ref="splitRight" class="split-right">
<MainEditor ref="mainEditor" v-model:markdownCode="markdownCode" :width="splitRightWidth"
:save="writeFileContent">
</MainEditor>
</div>
</template>
</Split>
</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>
<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" />
<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" />
</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>