feat: 优化实时渲染思维导图的保存流程

- 实现实时渲染思维导图保存时保持视图状态
- 避免重新渲染,只更新临时ID为正式ID
This commit is contained in:
lixinran 2025-09-10 13:02:45 +08:00
parent fd1b71dd75
commit caa763d808
7 changed files with 1818 additions and 7 deletions

187
MARKDOWN_INTEGRATION.md Normal file
View File

@ -0,0 +1,187 @@
# Markdown 节点渲染集成说明
## 概述
这个集成方案为你的 Mind Elixir 思维导图项目添加了 markdown 节点渲染能力,支持:
- ✅ **表格渲染** - 完整的 markdown 表格支持
- ✅ **代码高亮** - 代码块和行内代码
- ✅ **文本格式** - 粗体、斜体、标题等
- ✅ **列表** - 有序和无序列表
- ✅ **链接** - 自动链接渲染
- ✅ **智能检测** - 自动识别 markdown 语法
## 文件结构
```
frontend/src/
├── utils/
│ └── markdownRenderer.js # 核心渲染器
├── components/
│ ├── MindMap.vue # 主思维导图组件(已集成)
│ └── MarkdownTest.vue # 测试组件
└── ...
```
## 使用方法
### 1. 在节点中使用 markdown
现在你可以在思维导图的节点内容中直接使用 markdown 语法:
```markdown
# 产品价格表
| 产品 | 价格 | 库存 |
|------|------|------|
| 苹果 | 4元 | 100个 |
| 香蕉 | 2元 | 50个 |
## 技术栈
- **前端**: Vue.js 3
- **后端**: Django
- **数据库**: PostgreSQL
## 代码示例
\`\`\`javascript
function hello() {
console.log('Hello World!');
}
\`\`\`
```
### 2. 测试功能
访问 `MarkdownTest.vue` 组件来测试 markdown 渲染功能:
```vue
<template>
<MarkdownTest />
</template>
<script setup>
import MarkdownTest from './components/MarkdownTest.vue';
</script>
```
### 3. 在现有节点中启用
系统会自动检测节点内容是否包含 markdown 语法,如果包含,会自动使用 markdown 渲染器。
## 核心功能
### 智能渲染
```javascript
import { smartRenderNodeContent } from '../utils/markdownRenderer.js';
// 自动检测并渲染
smartRenderNodeContent(nodeElement, content);
```
### 手动渲染
```javascript
import { renderMarkdownToHTML } from '../utils/markdownRenderer.js';
// 直接渲染为 HTML
const html = renderMarkdownToHTML(markdownContent);
```
### 语法检测
```javascript
import { hasMarkdownSyntax } from '../utils/markdownRenderer.js';
// 检测是否包含 markdown 语法
if (hasMarkdownSyntax(content)) {
// 使用 markdown 渲染
}
```
## 支持的 Markdown 语法
### 表格
```markdown
| 列1 | 列2 | 列3 |
|-----|-----|-----|
| 数据1 | 数据2 | 数据3 |
```
### 代码块
```markdown
\`\`\`javascript
function test() {
console.log('Hello');
}
\`\`\`
```
### 行内代码
```markdown
使用 \`console.log()\` 输出信息
```
### 文本格式
```markdown
**粗体文本**
*斜体文本*
```
### 列表
```markdown
- 无序列表项1
- 无序列表项2
1. 有序列表项1
2. 有序列表项2
```
### 链接
```markdown
[链接文本](https://example.com)
```
## 样式定制
渲染器会自动添加 CSS 样式,你也可以通过以下类名进行自定义:
- `.markdown-content` - 主容器
- `.markdown-table` - 表格样式
- `.markdown-code` - 代码块样式
- `.markdown-math` - 数学公式样式
## 注意事项
1. **性能**: 大量 markdown 内容可能影响渲染性能
2. **安全性**: 渲染器允许 HTML请确保内容来源可信
3. **兼容性**: 与 Mind Elixir 的拖拽、编辑功能完全兼容
## 故障排除
### 渲染失败
- 检查 markdown 语法是否正确
- 查看浏览器控制台错误信息
- 使用 `MarkdownTest.vue` 组件测试
### 样式问题
- 检查 CSS 样式是否被覆盖
- 确保 `markdown-node-styles` 样式已加载
### 性能问题
- 避免在单个节点中放置过多内容
- 考虑将复杂内容拆分为多个子节点
## 扩展功能
如果需要更多功能,可以扩展 `markdownRenderer.js`
- 数学公式支持KaTeX
- 图表支持Mermaid
- 更多 markdown 扩展语法
## 总结
这个集成方案让你可以在保持现有 Mind Elixir 功能的同时,享受强大的 markdown 渲染能力。特别是表格渲染功能,让思维导图可以展示更丰富的数据结构。

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,8 @@
"axios": "^1.5.0", "axios": "^1.5.0",
"mammoth": "^1.10.0", "mammoth": "^1.10.0",
"marked": "^16.2.1", "marked": "^16.2.1",
"markmap-lib": "^0.18.12",
"markmap-view": "^0.18.12",
"mind-elixir": "^3.0.0", "mind-elixir": "^3.0.0",
"pdfjs-dist": "^5.4.149", "pdfjs-dist": "^5.4.149",
"vue": "^3.3.4" "vue": "^3.3.4"

View File

@ -1,11 +1,26 @@
<template> <template>
<div id="app"> <div id="app">
<!-- AI侧边栏 --> <!-- 测试模式切换按钮 -->
<AISidebar @start-realtime-generation="handleStartRealtimeGeneration" /> <div class="test-mode-toggle">
<button @click="toggleTestMode" class="test-btn">
{{ isTestMode ? '切换到思维导图' : '测试Markdown渲染' }}
</button>
</div>
<!-- 主内容区域 --> <!-- 测试模式 -->
<div class="main-content"> <div v-if="isTestMode" class="test-mode">
<MindMap ref="mindMapRef" /> <MarkdownTest />
</div>
<!-- 正常模式 -->
<div v-else>
<!-- AI侧边栏 -->
<AISidebar @start-realtime-generation="handleStartRealtimeGeneration" />
<!-- 主内容区域 -->
<div class="main-content">
<MindMap ref="mindMapRef" />
</div>
</div> </div>
</div> </div>
</template> </template>
@ -14,8 +29,15 @@
import { ref } from 'vue'; import { ref } from 'vue';
import MindMap from "./components/MindMap.vue"; import MindMap from "./components/MindMap.vue";
import AISidebar from "./components/AISidebar.vue"; import AISidebar from "./components/AISidebar.vue";
import MarkdownTest from "./components/MarkdownTest.vue";
const mindMapRef = ref(null); const mindMapRef = ref(null);
const isTestMode = ref(false);
//
const toggleTestMode = () => {
isTestMode.value = !isTestMode.value;
};
// //
const handleStartRealtimeGeneration = () => { const handleStartRealtimeGeneration = () => {
@ -65,4 +87,36 @@ body {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
/* 测试模式样式 */
.test-mode-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.test-btn {
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
transition: all 0.2s ease;
}
.test-btn:hover {
background: #0056b3;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.4);
}
.test-mode {
height: 100vh;
overflow: auto;
}
</style> </style>

View File

@ -0,0 +1,228 @@
<template>
<div class="markdown-test">
<h2>Markdown渲染测试</h2>
<div class="test-section">
<h3>输入Markdown内容</h3>
<textarea
v-model="markdownInput"
placeholder="输入markdown内容..."
rows="10"
class="markdown-input"
></textarea>
</div>
<div class="test-section">
<h3>渲染结果</h3>
<div class="rendered-content" v-html="renderedHTML"></div>
</div>
<div class="test-section">
<h3>测试用例</h3>
<button @click="loadTestCases" class="test-btn">加载测试用例</button>
<div class="test-cases">
<button
v-for="(testCase, index) in testCases"
:key="index"
@click="loadTestCase(testCase)"
class="test-case-btn"
>
{{ testCase.name }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { renderMarkdownToHTML, hasMarkdownSyntax } from '../utils/markdownRenderer.js';
//
const markdownInput = ref(`# 测试标题
这是一个**粗体***斜体*的测试
## 表格测试
| 产品 | 价格 | 库存 |
|------|------|------|
| 苹果 | 4 | 100 |
| 香蕉 | 2 | 50 |
## 代码测试
\`\`\`javascript
function hello() {
console.log('Hello World!');
}
\`\`\`
行内代码\`const name = 'test'\`
## 列表测试
- 项目1
- 项目2
- 子项目2.1
- 子项目2.2
- 项目3
## 链接测试
- [GitHub](https://github.com)
- [Vue.js](https://vuejs.org)`);
//
const testCases = ref([
{
name: '基础表格',
content: `# 产品价格表
| 产品 | 价格 |
|------|------|
| 苹果 | 4 |
| 香蕉 | 2 |`
},
{
name: '复杂表格',
content: `# 技术栈对比
| 技术 | 前端 | 后端 | 数据库 |
|------|------|------|--------|
| Vue.js | | | |
| Django | | | |
| PostgreSQL | | | |`
},
{
name: '代码块',
content: `# 代码示例
\`\`\`javascript
function markdownToJSON(markdown) {
const lines = markdown.split('\\n');
// ...
return result;
}
\`\`\``
},
{
name: '混合内容',
content: `# 混合内容测试
这是一个包含**粗体***斜体*\`行内代码\`的段落。
## 表格
| 功能 | 状态 | 说明 |
|------|------|------|
| 表格渲染 | | 支持markdown表格 |
| 代码高亮 | | 支持代码块 |
## 代码
\`\`\`python
def hello_world():
print("Hello, World!")
\`\`\``
}
]);
// HTML
const renderedHTML = computed(() => {
if (!markdownInput.value) return '';
try {
return renderMarkdownToHTML(markdownInput.value);
} catch (error) {
return `<div class="error">渲染失败: ${error.message}</div>`;
}
});
//
const loadTestCases = () => {
//
};
const loadTestCase = (testCase) => {
markdownInput.value = testCase.content;
};
</script>
<style scoped>
.markdown-test {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.test-section {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #f9f9f9;
}
.markdown-input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
resize: vertical;
}
.rendered-content {
background: white;
padding: 20px;
border-radius: 4px;
border: 1px solid #ddd;
min-height: 200px;
}
.test-cases {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.test-btn,
.test-case-btn {
padding: 8px 16px;
border: 1px solid #007bff;
background: white;
color: #007bff;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.test-btn:hover,
.test-case-btn:hover {
background: #007bff;
color: white;
}
h2, h3 {
color: #333;
margin-bottom: 15px;
}
h2 {
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
}
.error {
color: #dc3545;
background: #f8d7da;
border: 1px solid #f5c6cb;
padding: 10px;
border-radius: 4px;
}
</style>

View File

@ -171,6 +171,11 @@
import { ref, onMounted, onUnmounted, nextTick } from 'vue'; import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import MindElixir from 'mind-elixir'; import MindElixir from 'mind-elixir';
import { mindmapAPI } from '../api/mindmap.js'; import { mindmapAPI } from '../api/mindmap.js';
import {
smartRenderNodeContent,
hasMarkdownSyntax,
renderMarkdownToHTML
} from '../utils/markdownRenderer.js';
// //
const mindmapEl = ref(null); const mindmapEl = ref(null);
@ -2717,6 +2722,77 @@ const savePreviewToDatabase = async (data, title) => {
try { try {
// //
//
const currentId = String(currentMindmapId.value || '');
const hasRealtimeMindmap = mindElixir.value && currentId && currentId.startsWith('temp-');
if (hasRealtimeMindmap) {
console.log("🔄 检测到实时渲染的思维导图将保存到数据库并更新ID");
//
const currentPosition = saveCurrentPosition();
console.log("📍 保存当前位置:", currentPosition);
//
const response = await mindmapAPI.createMindmap(title || "预览思维导图", data);
console.log("🔄 创建思维导图响应:", response);
if (response.data && response.data.id) {
const newMindmapId = response.data.id;
console.log("🎉 创建思维导图成功新思维导图的ID是:", newMindmapId);
// ID
const oldMindmapId = currentMindmapId.value;
currentMindmapId.value = newMindmapId;
// MindElixirID
if (mindElixir.value && mindElixir.value.data) {
mindElixir.value.data.mindmapId = newMindmapId;
mindElixir.value.data.id = newMindmapId;
// mindmapId
const updateNodeIds = (node) => {
if (node) {
node.mindmapId = newMindmapId;
if (node.children) {
node.children.forEach(child => updateNodeIds(child));
}
}
};
if (mindElixir.value.data.nodeData) {
updateNodeIds(mindElixir.value.data.nodeData);
}
}
console.log("✅ 已更新思维导图ID保持视图状态");
console.log("🔄 从临时ID", oldMindmapId, "更新为正式ID", newMindmapId);
//
if (currentPosition) {
setTimeout(() => {
restorePosition(currentPosition);
console.log("📍 已恢复位置和缩放状态");
}, 100);
}
// AISidebar
window.dispatchEvent(new CustomEvent('mindmap-saved', {
detail: {
mindmapId: newMindmapId,
title: title,
timestamp: Date.now(),
fromRealtime: true //
}
}));
return;
}
}
// 使
console.log("🔄 没有检测到实时渲染的思维导图,使用标准保存流程");
// //
const response = await mindmapAPI.createMindmap(title || "预览思维导图", data); const response = await mindmapAPI.createMindmap(title || "预览思维导图", data);
console.log("🔄 创建思维导图响应:", response); console.log("🔄 创建思维导图响应:", response);
@ -3148,12 +3224,18 @@ const updateMindMapRealtime = async (data, title) => {
}); });
// 🔧 MindElixir // 🔧 MindElixir
const tempId = `temp-${Date.now()}`;
const mindElixirData = { const mindElixirData = {
nodeData: data, // nodeData nodeData: data, // nodeData
mindmapId: `temp-${Date.now()}`, // ID mindmapId: tempId, // ID
id: tempId, // id
title: title || 'AI生成中...' title: title || 'AI生成中...'
}; };
// IDID
currentMindmapId.value = tempId;
console.log('🆔 设置临时思维导图ID:', tempId);
// //
const result = mindElixir.value.init(mindElixirData); const result = mindElixir.value.init(mindElixirData);
console.log('✅ 实时思维导图实例创建成功'); console.log('✅ 实时思维导图实例创建成功');
@ -3176,12 +3258,24 @@ const updateMindMapRealtime = async (data, title) => {
console.log(' 当前位置已保存:', currentPosition); console.log(' 当前位置已保存:', currentPosition);
// 🔧 MindElixir // 🔧 MindElixir
const currentId = String(currentMindmapId.value || '');
const tempId = currentId && currentId.startsWith('temp-')
? currentId
: `temp-${Date.now()}`;
const mindElixirData = { const mindElixirData = {
nodeData: data, // nodeData nodeData: data, // nodeData
mindmapId: currentMindmapId.value || `temp-${Date.now()}`, mindmapId: tempId,
id: tempId,
title: title || 'AI生成中...' title: title || 'AI生成中...'
}; };
// IDID
if (!currentId || !currentId.startsWith('temp-')) {
currentMindmapId.value = tempId;
console.log('🆔 更新临时思维导图ID:', tempId);
}
// //
const result = mindElixir.value.init(mindElixirData); const result = mindElixir.value.init(mindElixirData);
console.log('✅ 思维导图数据更新成功'); console.log('✅ 思维导图数据更新成功');

View File

@ -0,0 +1,303 @@
/**
* Markdown节点渲染器
* 为Mind Elixir节点提供markdown内容渲染能力
* 支持表格数学公式代码块等
*/
import { marked } from 'marked';
// 配置marked选项
marked.setOptions({
breaks: true,
gfm: true, // GitHub Flavored Markdown
tables: true, // 支持表格
sanitize: false, // 允许HTML用于数学公式等
});
/**
* 渲染markdown内容为HTML
* @param {string} markdown - markdown文本
* @returns {string} 渲染后的HTML
*/
export const renderMarkdownToHTML = (markdown) => {
if (!markdown || typeof markdown !== 'string') {
return '';
}
try {
// 预处理markdown
const processedMarkdown = preprocessMarkdown(markdown);
// 使用marked渲染
const html = marked.parse(processedMarkdown);
// 后处理HTML
return postprocessHTML(html);
} catch (error) {
console.error('Markdown渲染失败:', error);
return `<div class="markdown-error">渲染失败: ${error.message}</div>`;
}
};
/**
* 预处理markdown内容
* @param {string} markdown - 原始markdown
* @returns {string} 处理后的markdown
*/
const preprocessMarkdown = (markdown) => {
return markdown
// 确保表格前后有空行
.replace(/([^\n])\n(\|.*\|)/g, '$1\n\n$2')
.replace(/(\|.*\|)\n([^\n|])/g, '$1\n\n$2')
// 处理数学公式(如果需要的话)
.replace(/\$\$(.*?)\$\$/g, '<div class="math-block">$$$1$$</div>')
.replace(/\$(.*?)\$/g, '<span class="math-inline">$$1$</span>');
};
/**
* 后处理HTML内容
* @param {string} html - 渲染后的HTML
* @returns {string} 处理后的HTML
*/
const postprocessHTML = (html) => {
return html
// 为表格添加样式类
.replace(/<table>/g, '<table class="markdown-table">')
// 为代码块添加样式类
.replace(/<pre><code/g, '<pre class="markdown-code"><code')
// 为数学公式添加样式类
.replace(/<div class="math-block">/g, '<div class="math-block markdown-math">')
.replace(/<span class="math-inline">/g, '<span class="math-inline markdown-math">');
};
/**
* 为Mind Elixir节点设置markdown内容
* @param {HTMLElement} nodeElement - 节点DOM元素
* @param {string} markdownContent - markdown内容
*/
export const setNodeMarkdownContent = (nodeElement, markdownContent) => {
if (!nodeElement || !markdownContent) return;
// 查找或创建内容容器
let contentContainer = nodeElement.querySelector('.markdown-content');
if (!contentContainer) {
contentContainer = document.createElement('div');
contentContainer.className = 'markdown-content';
// 将内容容器添加到节点中
const topicText = nodeElement.querySelector('.topic-text');
if (topicText) {
topicText.appendChild(contentContainer);
} else {
nodeElement.appendChild(contentContainer);
}
}
// 渲染markdown内容
const html = renderMarkdownToHTML(markdownContent);
contentContainer.innerHTML = html;
// 添加样式
addMarkdownStyles(contentContainer);
};
/**
* 添加markdown样式
* @param {HTMLElement} container - 容器元素
*/
const addMarkdownStyles = (container) => {
// 检查是否已经添加过样式
if (document.getElementById('markdown-node-styles')) return;
const style = document.createElement('style');
style.id = 'markdown-node-styles';
style.textContent = `
.markdown-content {
max-width: 100%;
overflow: hidden;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin: 4px 0 2px 0;
font-weight: 600;
color: #333;
}
.markdown-content h1 { font-size: 16px; }
.markdown-content h2 { font-size: 15px; }
.markdown-content h3 { font-size: 14px; }
.markdown-content h4 { font-size: 13px; }
.markdown-content h5 { font-size: 12px; }
.markdown-content h6 { font-size: 11px; }
.markdown-content p {
margin: 2px 0;
line-height: 1.3;
color: #666;
}
.markdown-content ul,
.markdown-content ol {
margin: 2px 0;
padding-left: 16px;
}
.markdown-content li {
margin: 1px 0;
line-height: 1.3;
color: #666;
}
.markdown-content strong,
.markdown-content b {
font-weight: 600;
color: #333;
}
.markdown-content em,
.markdown-content i {
font-style: italic;
color: #555;
}
.markdown-content code {
background: #f5f5f5;
padding: 1px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 11px;
color: #d63384;
}
.markdown-content pre {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 8px;
margin: 4px 0;
overflow-x: auto;
}
.markdown-content pre code {
background: none;
padding: 0;
color: #333;
font-size: 11px;
}
.markdown-table {
border-collapse: collapse;
width: 100%;
margin: 4px 0;
font-size: 11px;
}
.markdown-table th,
.markdown-table td {
border: 1px solid #ddd;
padding: 4px 6px;
text-align: left;
}
.markdown-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #333;
}
.markdown-table tr:nth-child(even) {
background-color: #f8f9fa;
}
.markdown-content a {
color: #007bff;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content blockquote {
border-left: 3px solid #ddd;
margin: 4px 0;
padding-left: 8px;
color: #666;
font-style: italic;
}
.markdown-math {
font-family: 'Times New Roman', serif;
}
.markdown-error {
color: #dc3545;
background: #f8d7da;
border: 1px solid #f5c6cb;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
}
`;
document.head.appendChild(style);
};
/**
* 检查内容是否包含markdown语法
* @param {string} content - 内容文本
* @returns {boolean} 是否包含markdown语法
*/
export const hasMarkdownSyntax = (content) => {
if (!content || typeof content !== 'string') return false;
// 检查常见的markdown语法
const markdownPatterns = [
/#{1,6}\s+/, // 标题
/\*\*.*?\*\*/, // 粗体
/\*.*?\*/, // 斜体
/`.*?`/, // 行内代码
/```[\s\S]*?```/, // 代码块
/\|.*\|/, // 表格
/^\s*[-*+]\s+/m, // 列表
/^\s*\d+\.\s+/m, // 有序列表
/\[.*?\]\(.*?\)/, // 链接
/!\[.*?\]\(.*?\)/, // 图片
];
return markdownPatterns.some(pattern => pattern.test(content));
};
/**
* 智能渲染节点内容
* 根据内容类型选择渲染方式
* @param {HTMLElement} nodeElement - 节点DOM元素
* @param {string} content - 节点内容
*/
export const smartRenderNodeContent = (nodeElement, content) => {
if (!nodeElement || !content) return;
// 检查是否包含markdown语法
if (hasMarkdownSyntax(content)) {
// 使用markdown渲染
setNodeMarkdownContent(nodeElement, content);
} else {
// 使用普通文本渲染
const topicText = nodeElement.querySelector('.topic-text');
if (topicText) {
topicText.textContent = content;
}
}
};
export default {
renderMarkdownToHTML,
setNodeMarkdownContent,
hasMarkdownSyntax,
smartRenderNodeContent
};