feat(editor): 添加目录大纲侧边栏功能
- 在编辑器工具栏添加目录切换按钮,支持显示/隐藏目录侧边栏 - 实现实时目录生成功能,自动解析Markdown标题并生成大纲 - 添加目录点击跳转功能,点击目录项可滚动到对应标题位置 - 优化编辑器布局,支持侧边栏展开/收起动画效果
This commit is contained in:
parent
b952be4c4a
commit
97caefa673
|
|
@ -1,6 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
<a-space :size="4">
|
<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-dropdown :trigger="['click']">
|
||||||
<a-button type="text" size="small" title="标题">
|
<a-button type="text" size="small" title="标题">
|
||||||
|
|
@ -66,9 +76,15 @@ import {
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
OrderedListOutlined,
|
OrderedListOutlined,
|
||||||
LinkOutlined
|
LinkOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
MenuFoldOutlined
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
tocVisible: Boolean
|
||||||
|
});
|
||||||
|
|
||||||
defineEmits(['action']);
|
defineEmits(['action']);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,37 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="live-editor" @click="handleEditorClick">
|
<div class="live-editor" @click="handleEditorClick">
|
||||||
<EditorToolbar @action="handleToolbarAction" />
|
<EditorToolbar @action="handleToolbarAction" :tocVisible="showToc" />
|
||||||
<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" />
|
|
||||||
|
<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">
|
<Teleport to="body">
|
||||||
|
|
@ -51,6 +81,55 @@ const { onEditorAction } = useEditor();
|
||||||
const { activeTab } = useTabs();
|
const { activeTab } = useTabs();
|
||||||
const { applyHeading, wrapSelection, prefixLines, insertLink } = useMarkdownActions();
|
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 扩展
|
// 配置 Markdown 扩展
|
||||||
const markdownExtension = markdown({ base: markdownLanguage, codeLanguages: languages });
|
const markdownExtension = markdown({ base: markdownLanguage, codeLanguages: languages });
|
||||||
|
|
||||||
|
|
@ -67,6 +146,7 @@ const liveEditorTheme = computed(() => {
|
||||||
},
|
},
|
||||||
".cm-scroller": {
|
".cm-scroller": {
|
||||||
fontFamily: "var(--app-font-family)",
|
fontFamily: "var(--app-font-family)",
|
||||||
|
scrollBehavior: "smooth",
|
||||||
},
|
},
|
||||||
".cm-content": {
|
".cm-content": {
|
||||||
padding: "30px 40px",
|
padding: "30px 40px",
|
||||||
|
|
@ -75,8 +155,6 @@ const liveEditorTheme = computed(() => {
|
||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
caretColor: "var(--primary-color)",
|
caretColor: "var(--primary-color)",
|
||||||
},
|
},
|
||||||
// ... 其他保持不变,但需要确保 monospaceFont 的地方也改了
|
|
||||||
|
|
||||||
".cm-selectionBackground, ::selection": {
|
".cm-selectionBackground, ::selection": {
|
||||||
backgroundColor: "var(--primary-color-bg)",
|
backgroundColor: "var(--primary-color-bg)",
|
||||||
},
|
},
|
||||||
|
|
@ -92,7 +170,6 @@ const liveEditorTheme = computed(() => {
|
||||||
".cm-activeLineGutter": {
|
".cm-activeLineGutter": {
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Markdown 语法标记样式
|
// Markdown 语法标记样式
|
||||||
".cm-md-syntax": {
|
".cm-md-syntax": {
|
||||||
color: "var(--text-color-secondary)",
|
color: "var(--text-color-secondary)",
|
||||||
|
|
@ -104,7 +181,6 @@ const liveEditorTheme = computed(() => {
|
||||||
".cm-md-syntax-hidden": {
|
".cm-md-syntax-hidden": {
|
||||||
display: "none !important",
|
display: "none !important",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 标题样式
|
// 标题样式
|
||||||
".cm-line.cm-md-heading": {
|
".cm-line.cm-md-heading": {
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
|
|
@ -127,7 +203,6 @@ const liveEditorTheme = computed(() => {
|
||||||
".cm-line.cm-md-h4": { fontSize: "1.2em" },
|
".cm-line.cm-md-h4": { fontSize: "1.2em" },
|
||||||
".cm-line.cm-md-h5": { fontSize: "1.1em" },
|
".cm-line.cm-md-h5": { fontSize: "1.1em" },
|
||||||
".cm-line.cm-md-h6": { fontSize: "1em", color: "var(--text-color-secondary)" },
|
".cm-line.cm-md-h6": { fontSize: "1em", color: "var(--text-color-secondary)" },
|
||||||
|
|
||||||
// 引用样式
|
// 引用样式
|
||||||
".cm-line.cm-md-blockquote": {
|
".cm-line.cm-md-blockquote": {
|
||||||
color: "var(--text-color-secondary)",
|
color: "var(--text-color-secondary)",
|
||||||
|
|
@ -135,7 +210,6 @@ const liveEditorTheme = computed(() => {
|
||||||
paddingLeft: "16px",
|
paddingLeft: "16px",
|
||||||
margin: "4px 0",
|
margin: "4px 0",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 列表样式
|
// 列表样式
|
||||||
".cm-line.cm-md-list": {
|
".cm-line.cm-md-list": {
|
||||||
paddingLeft: "4px",
|
paddingLeft: "4px",
|
||||||
|
|
@ -143,7 +217,6 @@ const liveEditorTheme = computed(() => {
|
||||||
".cm-line.cm-md-task": {
|
".cm-line.cm-md-task": {
|
||||||
paddingLeft: "4px",
|
paddingLeft: "4px",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 行内代码样式
|
// 行内代码样式
|
||||||
".cm-md-monospace": {
|
".cm-md-monospace": {
|
||||||
fontFamily: "var(--editor-font-family)",
|
fontFamily: "var(--editor-font-family)",
|
||||||
|
|
@ -152,7 +225,6 @@ const liveEditorTheme = computed(() => {
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
fontSize: "0.9em",
|
fontSize: "0.9em",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 代码块样式
|
// 代码块样式
|
||||||
".cm-line.cm-md-fenced-code": {
|
".cm-line.cm-md-fenced-code": {
|
||||||
fontFamily: "var(--editor-font-family)",
|
fontFamily: "var(--editor-font-family)",
|
||||||
|
|
@ -160,7 +232,6 @@ const liveEditorTheme = computed(() => {
|
||||||
paddingLeft: "16px",
|
paddingLeft: "16px",
|
||||||
fontSize: "0.9em",
|
fontSize: "0.9em",
|
||||||
},
|
},
|
||||||
// 激活状态的代码块(稍微深一点,或者保持一致)
|
|
||||||
".cm-line.cm-md-fenced-code.cm-active": {
|
".cm-line.cm-md-fenced-code.cm-active": {
|
||||||
backgroundColor: "var(--hover-background)",
|
backgroundColor: "var(--hover-background)",
|
||||||
outline: "1px solid var(--border-color)",
|
outline: "1px solid var(--border-color)",
|
||||||
|
|
@ -172,7 +243,6 @@ const liveEditorTheme = computed(() => {
|
||||||
float: "right",
|
float: "right",
|
||||||
marginRight: "8px",
|
marginRight: "8px",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 表格预览样式 (Widget)
|
// 表格预览样式 (Widget)
|
||||||
".cm-md-table-preview": {
|
".cm-md-table-preview": {
|
||||||
display: "block",
|
display: "block",
|
||||||
|
|
@ -197,14 +267,12 @@ const liveEditorTheme = computed(() => {
|
||||||
fontFamily: "var(--editor-font-family)",
|
fontFamily: "var(--editor-font-family)",
|
||||||
whiteSpace: "pre",
|
whiteSpace: "pre",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 分割线预览样式
|
// 分割线预览样式
|
||||||
".cm-md-hr-preview": {
|
".cm-md-hr-preview": {
|
||||||
border: "none",
|
border: "none",
|
||||||
borderBottom: "2px solid var(--border-color)",
|
borderBottom: "2px solid var(--border-color)",
|
||||||
margin: "24px 0",
|
margin: "24px 0",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 图片预览样式
|
// 图片预览样式
|
||||||
".cm-md-image-preview": {
|
".cm-md-image-preview": {
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
|
|
@ -214,14 +282,12 @@ const liveEditorTheme = computed(() => {
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 链接文本样式
|
// 链接文本样式
|
||||||
".cm-md-link-text": {
|
".cm-md-link-text": {
|
||||||
color: "var(--primary-color)",
|
color: "var(--primary-color)",
|
||||||
textDecoration: "underline",
|
textDecoration: "underline",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 任务列表样式
|
// 任务列表样式
|
||||||
".cm-md-task-checkbox": {
|
".cm-md-task-checkbox": {
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
|
|
@ -229,7 +295,6 @@ const liveEditorTheme = computed(() => {
|
||||||
verticalAlign: "middle",
|
verticalAlign: "middle",
|
||||||
cursor: "default",
|
cursor: "default",
|
||||||
},
|
},
|
||||||
|
|
||||||
// 删除线样式
|
// 删除线样式
|
||||||
".cm-md-strikethrough": {
|
".cm-md-strikethrough": {
|
||||||
textDecoration: "line-through",
|
textDecoration: "line-through",
|
||||||
|
|
@ -258,6 +323,11 @@ watch(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 监听代码变化生成大纲
|
||||||
|
watch(code, (val) => {
|
||||||
|
generateToc(val);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
const handleChange = (val) => {
|
const handleChange = (val) => {
|
||||||
emit("update:modelValue", val);
|
emit("update:modelValue", val);
|
||||||
};
|
};
|
||||||
|
|
@ -287,6 +357,10 @@ const handleReady = (payload) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToolbarAction = (action, payload) => {
|
const handleToolbarAction = (action, payload) => {
|
||||||
|
if (action === 'toggleToc') {
|
||||||
|
showToc.value = !showToc.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!editorView.value) return;
|
if (!editorView.value) return;
|
||||||
executeAction(editorView.value, action, payload);
|
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({
|
const previewImage = reactive({
|
||||||
visible: false,
|
visible: false,
|
||||||
|
|
@ -366,6 +460,67 @@ onUnmounted(() => {
|
||||||
background: var(--card-background);
|
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) {
|
:deep(.ͼ7) {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue