feat(editor): 添加目录大纲侧边栏功能

- 在编辑器工具栏添加目录切换按钮,支持显示/隐藏目录侧边栏
- 实现实时目录生成功能,自动解析Markdown标题并生成大纲
- 添加目录点击跳转功能,点击目录项可滚动到对应标题位置
- 优化编辑器布局,支持侧边栏展开/收起动画效果
This commit is contained in:
cfq 2026-01-30 17:27:23 +08:00
parent b952be4c4a
commit 97caefa673
2 changed files with 190 additions and 19 deletions

View File

@ -1,6 +1,16 @@
<template>
<div class="editor-toolbar">
<a-space :size="4">
<!-- 目录切换 -->
<a-button type="text" size="small" @click="$emit('action', 'toggleToc')" :title="tocVisible ? '隐藏目录' : '显示目录'">
<template #icon>
<MenuFoldOutlined v-if="tocVisible" />
<MenuUnfoldOutlined v-else />
</template>
</a-button>
<a-divider type="vertical" style="height: 1.2em; background-color: var(--border-color);" />
<!-- 标题 -->
<a-dropdown :trigger="['click']">
<a-button type="text" size="small" title="标题">
@ -66,9 +76,15 @@ import {
MessageOutlined,
UnorderedListOutlined,
OrderedListOutlined,
LinkOutlined
LinkOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined
} from '@ant-design/icons-vue';
defineProps({
tocVisible: Boolean
});
defineEmits(['action']);
</script>

View File

@ -1,7 +1,37 @@
<template>
<div class="live-editor" @click="handleEditorClick">
<EditorToolbar @action="handleToolbarAction" />
<codemirror v-model="code" placeholder="请输入 Markdown 内容..." :style="{ height: '0', flex: 1, fontSize: 'var(--editor-font-size)', color: 'var(--primary-color)' }" :autofocus="true" :indent-with-tab="true" :tab-size="2" :extensions="extensions" @ready="handleReady" @change="handleChange" />
<EditorToolbar @action="handleToolbarAction" :tocVisible="showToc" />
<div class="editor-main">
<!-- 目录大纲侧边栏 -->
<div class="toc-sidebar" :class="{ collapsed: !showToc }">
<div class="toc-content">
<div v-if="toc.length === 0" class="toc-empty">暂无大纲</div>
<div
v-for="(item, index) in toc"
:key="index"
class="toc-item"
:class="`toc-level-${item.level}`"
@click.stop="scrollToHeader(item.line)"
:title="item.text"
>
{{ item.text }}
</div>
</div>
</div>
<codemirror
v-model="code"
placeholder="请输入 Markdown 内容..."
:style="{ height: '100%', flex: 1, fontSize: 'var(--editor-font-size)', color: 'var(--primary-color)' }"
:autofocus="true"
:indent-with-tab="true"
:tab-size="2"
:extensions="extensions"
@ready="handleReady"
@change="handleChange"
/>
</div>
<!-- 图片预览弹窗 -->
<Teleport to="body">
@ -51,6 +81,55 @@ const { onEditorAction } = useEditor();
const { activeTab } = useTabs();
const { applyHeading, wrapSelection, prefixLines, insertLink } = useMarkdownActions();
//
const showToc = ref(true);
const toc = shallowRef([]);
const debounce = (fn, delay) => {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
};
const generateToc = debounce((content) => {
if (!content) {
toc.value = [];
return;
}
const lines = content.split('\n');
const newToc = [];
// #
const reg = /^(#{1,6})\s+(.+)$/;
let inCodeBlock = false;
lines.forEach((line, index) => {
//
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
return;
}
if (inCodeBlock) return;
const match = line.match(reg);
if (match) {
newToc.push({
level: match[1].length,
text: match[2].trim(),
line: index + 1
});
}
});
toc.value = newToc;
}, 300);
// Markdown
const markdownExtension = markdown({ base: markdownLanguage, codeLanguages: languages });
@ -67,6 +146,7 @@ const liveEditorTheme = computed(() => {
},
".cm-scroller": {
fontFamily: "var(--app-font-family)",
scrollBehavior: "smooth",
},
".cm-content": {
padding: "30px 40px",
@ -75,8 +155,6 @@ const liveEditorTheme = computed(() => {
margin: "0 auto",
caretColor: "var(--primary-color)",
},
// ... monospaceFont
".cm-selectionBackground, ::selection": {
backgroundColor: "var(--primary-color-bg)",
},
@ -92,7 +170,6 @@ const liveEditorTheme = computed(() => {
".cm-activeLineGutter": {
backgroundColor: "transparent",
},
// Markdown
".cm-md-syntax": {
color: "var(--text-color-secondary)",
@ -104,7 +181,6 @@ const liveEditorTheme = computed(() => {
".cm-md-syntax-hidden": {
display: "none !important",
},
//
".cm-line.cm-md-heading": {
fontWeight: "600",
@ -127,7 +203,6 @@ const liveEditorTheme = computed(() => {
".cm-line.cm-md-h4": { fontSize: "1.2em" },
".cm-line.cm-md-h5": { fontSize: "1.1em" },
".cm-line.cm-md-h6": { fontSize: "1em", color: "var(--text-color-secondary)" },
//
".cm-line.cm-md-blockquote": {
color: "var(--text-color-secondary)",
@ -135,7 +210,6 @@ const liveEditorTheme = computed(() => {
paddingLeft: "16px",
margin: "4px 0",
},
//
".cm-line.cm-md-list": {
paddingLeft: "4px",
@ -143,7 +217,6 @@ const liveEditorTheme = computed(() => {
".cm-line.cm-md-task": {
paddingLeft: "4px",
},
//
".cm-md-monospace": {
fontFamily: "var(--editor-font-family)",
@ -152,7 +225,6 @@ const liveEditorTheme = computed(() => {
borderRadius: "4px",
fontSize: "0.9em",
},
//
".cm-line.cm-md-fenced-code": {
fontFamily: "var(--editor-font-family)",
@ -160,7 +232,6 @@ const liveEditorTheme = computed(() => {
paddingLeft: "16px",
fontSize: "0.9em",
},
//
".cm-line.cm-md-fenced-code.cm-active": {
backgroundColor: "var(--hover-background)",
outline: "1px solid var(--border-color)",
@ -172,7 +243,6 @@ const liveEditorTheme = computed(() => {
float: "right",
marginRight: "8px",
},
// (Widget)
".cm-md-table-preview": {
display: "block",
@ -197,14 +267,12 @@ const liveEditorTheme = computed(() => {
fontFamily: "var(--editor-font-family)",
whiteSpace: "pre",
},
// 线
".cm-md-hr-preview": {
border: "none",
borderBottom: "2px solid var(--border-color)",
margin: "24px 0",
},
//
".cm-md-image-preview": {
maxWidth: "100%",
@ -214,14 +282,12 @@ const liveEditorTheme = computed(() => {
borderRadius: "4px",
cursor: "default",
},
//
".cm-md-link-text": {
color: "var(--primary-color)",
textDecoration: "underline",
cursor: "pointer",
},
//
".cm-md-task-checkbox": {
display: "inline-block",
@ -229,7 +295,6 @@ const liveEditorTheme = computed(() => {
verticalAlign: "middle",
cursor: "default",
},
// 线
".cm-md-strikethrough": {
textDecoration: "line-through",
@ -258,6 +323,11 @@ watch(
},
);
//
watch(code, (val) => {
generateToc(val);
}, { immediate: true });
const handleChange = (val) => {
emit("update:modelValue", val);
};
@ -287,6 +357,10 @@ const handleReady = (payload) => {
};
const handleToolbarAction = (action, payload) => {
if (action === 'toggleToc') {
showToc.value = !showToc.value;
return;
}
if (!editorView.value) return;
executeAction(editorView.value, action, payload);
};
@ -323,6 +397,26 @@ const executeAction = (view, action, payload) => {
}
};
const scrollToHeader = (lineNumber) => {
if (!editorView.value) return;
const view = editorView.value;
//
const line = view.state.doc.line(lineNumber);
//
view.dispatch({
effects: EditorView.scrollIntoView(line.from, {
y: 'start',
yMargin: 20
}),
selection: { anchor: line.from }
});
//
view.focus();
};
//
const previewImage = reactive({
visible: false,
@ -366,6 +460,67 @@ onUnmounted(() => {
background: var(--card-background);
}
.editor-main {
display: flex;
flex: 1;
height: 0;
width: 100%;
overflow: hidden;
}
.toc-sidebar {
width: 220px;
flex-shrink: 0;
border-right: 1px solid var(--border-color);
overflow-y: auto;
overflow-x: hidden;
background-color: var(--card-background);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 1;
}
.toc-sidebar.collapsed {
width: 0;
opacity: 0;
border-right-width: 0;
}
.toc-content {
width: 220px; /* 固定宽度,防止内容挤压 */
padding: 12px 0;
}
.toc-empty {
padding: 20px;
text-align: center;
color: var(--text-color-secondary);
font-size: 13px;
}
.toc-item {
padding: 6px 12px;
cursor: pointer;
color: var(--text-color);
font-size: 14px;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: all 0.2s;
}
.toc-item:hover {
background-color: var(--hover-background);
color: var(--primary-color);
}
.toc-level-1 { padding-left: 12px; font-weight: 600; }
.toc-level-2 { padding-left: 24px; }
.toc-level-3 { padding-left: 36px; }
.toc-level-4 { padding-left: 48px; font-size: 0.9em; color: var(--text-color-secondary); }
.toc-level-5 { padding-left: 60px; font-size: 0.9em; color: var(--text-color-secondary); }
.toc-level-6 { padding-left: 72px; font-size: 0.9em; color: var(--text-color-secondary); }
:deep(.ͼ7) {
text-decoration: none;
}