207 lines
6.2 KiB
React
207 lines
6.2 KiB
React
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|||
|
|
import { Transformer } from 'markmap-lib';
|
|||
|
|
import { Markmap } from 'markmap-view';
|
|||
|
|
import { Loader } from 'lucide-react';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* MindMap - 纯展示组件,用于渲染Markdown内容的思维导图
|
|||
|
|
*
|
|||
|
|
* 设计原则:
|
|||
|
|
* 1. 组件只负责渲染脑图,不处理数据获取
|
|||
|
|
* 2. 不包含导出功能,导出由父组件处理
|
|||
|
|
* 3. 通过props传入已准备好的content
|
|||
|
|
*
|
|||
|
|
* @param {Object} props
|
|||
|
|
* @param {string} props.content - Markdown格式的内容(必须由父组件准备好)
|
|||
|
|
* @param {string} props.title - 标题(用于显示)
|
|||
|
|
* @param {number} props.initialScale - 初始缩放倍数,默认为1.8
|
|||
|
|
*/
|
|||
|
|
const MindMap = ({ content, title, initialScale = 1.8 }) => {
|
|||
|
|
const [markdown, setMarkdown] = useState('');
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [error, setError] = useState('');
|
|||
|
|
|
|||
|
|
const svgRef = useRef(null);
|
|||
|
|
const markmapRef = useRef(null);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (content) {
|
|||
|
|
setMarkdown(content);
|
|||
|
|
setLoading(false);
|
|||
|
|
} else {
|
|||
|
|
setMarkdown('# 暂无内容\n\n等待内容生成后查看思维导图。');
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [content]);
|
|||
|
|
|
|||
|
|
// 提取关键短语的函数
|
|||
|
|
const extractKeyPhrases = (text) => {
|
|||
|
|
// 移除markdown格式
|
|||
|
|
const cleanText = text.replace(/\*\*([^*]+)\*\*/g, '$1');
|
|||
|
|
|
|||
|
|
// 按标点符号分割
|
|||
|
|
const phrases = cleanText.split(/[,。;、:]/);
|
|||
|
|
const keyPhrases = [];
|
|||
|
|
|
|||
|
|
phrases.forEach(phrase => {
|
|||
|
|
const trimmed = phrase.trim();
|
|||
|
|
// 只保留包含重要关键词的短语,且长度适中
|
|||
|
|
if (trimmed.length > 4 && trimmed.length < 40) {
|
|||
|
|
const hasKeywords = /(?:项目|收入|问题|产品|团队|开发|验收|成本|功能|市场|合作|资源|计划|目标|业务|投入|效率|协作|管理|分析|讨论|决策|优化|整合)/.test(trimmed);
|
|||
|
|
if (hasKeywords) {
|
|||
|
|
keyPhrases.push(trimmed);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 如果没有找到关键短语,至少保留一个总结性的短语
|
|||
|
|
if (keyPhrases.length === 0 && phrases.length > 0) {
|
|||
|
|
const firstPhrase = phrases[0].trim();
|
|||
|
|
if (firstPhrase.length > 0 && firstPhrase.length < 50) {
|
|||
|
|
keyPhrases.push(firstPhrase);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 最多返回3个关键短语
|
|||
|
|
return keyPhrases.slice(0, 3);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 预处理markdown,确保格式适合生成思维导图
|
|||
|
|
const preprocessMarkdownForMindMap = (markdown, rootTitle) => {
|
|||
|
|
if (!markdown || markdown.trim() === '') return '# 暂无内容';
|
|||
|
|
|
|||
|
|
let processed = markdown.trim();
|
|||
|
|
|
|||
|
|
// 移除分隔线
|
|||
|
|
processed = processed.replace(/^---+$/gm, '');
|
|||
|
|
|
|||
|
|
// 检查是否有主标题,如果有就替换为rootTitle,如果没有就添加
|
|||
|
|
const lines = processed.split('\n');
|
|||
|
|
const firstLine = lines[0].trim();
|
|||
|
|
|
|||
|
|
if (firstLine.match(/^#\s+/)) {
|
|||
|
|
// 如果第一行是主标题,替换为rootTitle
|
|||
|
|
lines[0] = `# ${rootTitle || '内容总结'}`;
|
|||
|
|
processed = lines.join('\n');
|
|||
|
|
} else {
|
|||
|
|
// 如果没有主标题,添加一个
|
|||
|
|
processed = `# ${rootTitle || '内容总结'}\n\n${processed}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const processedLines = [];
|
|||
|
|
const contentLines = processed.split('\n');
|
|||
|
|
let i = 0;
|
|||
|
|
|
|||
|
|
while (i < contentLines.length) {
|
|||
|
|
const line = contentLines[i].trim();
|
|||
|
|
|
|||
|
|
if (line === '') {
|
|||
|
|
i++;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理标题行 - 保持标题格式不变
|
|||
|
|
if (line.match(/^#+\s+/)) {
|
|||
|
|
// 清理标题格式,移除粗体和多余符号,但保持标题符号
|
|||
|
|
let cleanTitle = line.replace(/\*\*([^*]+)\*\*/g, '$1'); // 移除粗体
|
|||
|
|
processedLines.push(cleanTitle);
|
|||
|
|
|
|||
|
|
// 获取标题级别
|
|||
|
|
const titleLevel = (line.match(/^#+/) || [''])[0].length;
|
|||
|
|
}
|
|||
|
|
// 处理现有列表项(有序和无序) - 保持其原始结构
|
|||
|
|
else if (line.match(/^\s*([-*+]|\d+\.)\s+/)) {
|
|||
|
|
// 只移除加粗格式,保留原始行,包括缩进和列表标记
|
|||
|
|
const cleanedLine = line.replace(/\*\*([^*]+)\*\*/g, '$1');
|
|||
|
|
processedLines.push(cleanedLine);
|
|||
|
|
}
|
|||
|
|
// 将区块引用转换为列表项
|
|||
|
|
else if (line.startsWith('>')) {
|
|||
|
|
const content = line.replace(/^>+\s*/, '');
|
|||
|
|
processedLines.push(`- ${content}`);
|
|||
|
|
}
|
|||
|
|
// 保持表格原样,让markmap自己处理
|
|||
|
|
else if (line.includes('|')) {
|
|||
|
|
processedLines.push(line);
|
|||
|
|
}
|
|||
|
|
// 处理其他普通段落 - 保留原样
|
|||
|
|
else if (line.length > 0 && !line.match(/\*\*总字数:\d+字\*\*/)) {
|
|||
|
|
processedLines.push(line);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
i++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清理结果
|
|||
|
|
const result = processedLines
|
|||
|
|
.filter(line => line.trim().length > 0)
|
|||
|
|
.join('\n');
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (loading || !markdown || !svgRef.current) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const processedMarkdown = preprocessMarkdownForMindMap(markdown, title);
|
|||
|
|
|
|||
|
|
const transformer = new Transformer();
|
|||
|
|
const { root } = transformer.transform(processedMarkdown);
|
|||
|
|
|
|||
|
|
if (markmapRef.current) {
|
|||
|
|
markmapRef.current.setData(root);
|
|||
|
|
} else {
|
|||
|
|
markmapRef.current = Markmap.create(svgRef.current, null, root);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
markmapRef.current.fit();
|
|||
|
|
|
|||
|
|
// 延迟一下再次调用fit并放大,确保内容完全渲染
|
|||
|
|
setTimeout(() => {
|
|||
|
|
if (markmapRef.current) {
|
|||
|
|
markmapRef.current.fit();
|
|||
|
|
// 直接设置为指定的缩放倍数
|
|||
|
|
try {
|
|||
|
|
markmapRef.current.rescale(initialScale);
|
|||
|
|
} catch (e) {
|
|||
|
|
console.log('缩放调整失败:', e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}, 500);
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('思维导图渲染失败:', error);
|
|||
|
|
setError('思维导图渲染失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
}, [markdown, loading, title, initialScale]);
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<div className="mindmap-loading">
|
|||
|
|
<Loader className="animate-spin" />
|
|||
|
|
<p>正在加载思维导图...</p>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (error) {
|
|||
|
|
return (
|
|||
|
|
<div className="mindmap-error">
|
|||
|
|
<p>{error}</p>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="mindmap-container">
|
|||
|
|
<div className="markmap-render-area">
|
|||
|
|
<svg ref={svgRef} style={{ width: '100%', height: '100%' }} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default MindMap;
|