feat(editor): 添加目录大纲侧边栏功能
- 在编辑器工具栏添加目录切换按钮,支持显示/隐藏目录侧边栏 - 实现实时目录生成功能,自动解析Markdown标题并生成大纲 - 添加目录点击跳转功能,点击目录项可滚动到对应标题位置 - 优化编辑器布局,支持侧边栏展开/收起动画效果
This commit is contained in:
parent
b952be4c4a
commit
97caefa673
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue