|
|
@ -165,7 +165,7 @@ MindMap/
|
|||
│ ├── test-*.html # 功能测试文件
|
||||
│ ├── package.json # 前端依赖
|
||||
│ └── vite.config.js # Vite配置
|
||||
├── mind-elixir-core-master/ # MindElixir完整源码
|
||||
../mind-elixir-core-master/ # MindElixir完整源码(平级目录)
|
||||
│ ├── src/ # TypeScript源码
|
||||
│ ├── tests/ # 测试套件
|
||||
│ ├── dist/ # 编译后文件
|
||||
|
|
@ -410,7 +410,7 @@ MIT License - 详见 [LICENSE](LICENSE) 文件
|
|||
- ✅ **MindElixir增强**: 集成了自定义的Markdown和表格渲染功能
|
||||
|
||||
### 文件结构优化
|
||||
- **保留**: `mind-elixir-core-master/` - 完整的源码和文档
|
||||
- **保留**: `../mind-elixir-core-master/` - 完整的源码和文档(平级目录)
|
||||
- **保留**: `frontend/src/lib/mind-elixir/` - 项目中使用的增强版本
|
||||
- **保留**: 核心测试文件 - 用于功能验证和问题调试
|
||||
- **删除**: 重复的调试文件和过时的测试文件
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ def call_ai_api(system_prompt, user_prompt, model="glm-4.5", base_url="https://o
|
|||
model=model,
|
||||
messages=messages,
|
||||
temperature=0.7,
|
||||
max_tokens=4000, # 减少token限制,提高响应速度
|
||||
max_tokens=8000, # 增加token限制,确保完整内容生成
|
||||
stream=stream
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 4.2.7 on 2025-10-09 07:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mindmap', '0002_rename_mindmapnode_node'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='node',
|
||||
name='image_fit',
|
||||
field=models.CharField(blank=True, default='contain', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='node',
|
||||
name='image_height',
|
||||
field=models.IntegerField(blank=True, default=80, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='node',
|
||||
name='image_url',
|
||||
field=models.URLField(blank=True, max_length=1000, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='node',
|
||||
name='image_width',
|
||||
field=models.IntegerField(blank=True, default=120, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -28,6 +28,13 @@ class Node(models.Model):
|
|||
depth = models.IntegerField(default=0) # depth
|
||||
title = models.CharField(max_length=500, blank=True, default='') # title
|
||||
desc = models.TextField(blank=True, default='') # des
|
||||
|
||||
# 图片相关字段
|
||||
image_url = models.URLField(max_length=1000, blank=True, null=True) # 图片URL
|
||||
image_width = models.IntegerField(default=120, null=True, blank=True) # 图片宽度
|
||||
image_height = models.IntegerField(default=80, null=True, blank=True) # 图片高度
|
||||
image_fit = models.CharField(max_length=20, default='contain', blank=True) # 图片适配方式
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True) # createDate
|
||||
updated_at = models.DateTimeField(auto_now=True) # updateDate
|
||||
deleted = models.BooleanField(default=False) # delete
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ def convert_to_mindelixir_format(mindmap, nodes):
|
|||
# 创建节点映射
|
||||
node_map = {}
|
||||
for node in nodes:
|
||||
node_map[str(node.id)] = {
|
||||
node_data = {
|
||||
"id": str(node.id),
|
||||
"topic": node.title or "无标题",
|
||||
"data": {
|
||||
|
|
@ -51,6 +51,17 @@ def convert_to_mindelixir_format(mindmap, nodes):
|
|||
"mindmap_id": mindmap.id
|
||||
}
|
||||
|
||||
# 添加图片信息
|
||||
if node.image_url:
|
||||
node_data["image"] = {
|
||||
"url": node.image_url,
|
||||
"width": node.image_width or 120,
|
||||
"height": node.image_height or 80,
|
||||
"fit": node.image_fit or "contain"
|
||||
}
|
||||
|
||||
node_map[str(node.id)] = node_data
|
||||
|
||||
# 构建树形结构
|
||||
root_nodes = []
|
||||
for node in nodes:
|
||||
|
|
@ -159,6 +170,11 @@ def create_mindmap(request):
|
|||
children_count=0,
|
||||
depth=0,
|
||||
deleted=False,
|
||||
# 添加图片信息
|
||||
image_url=mindmap_data.get('image', {}).get('url') if mindmap_data.get('image') else None,
|
||||
image_width=mindmap_data.get('image', {}).get('width') if mindmap_data.get('image') else None,
|
||||
image_height=mindmap_data.get('image', {}).get('height') if mindmap_data.get('image') else None,
|
||||
image_fit=mindmap_data.get('image', {}).get('fit') if mindmap_data.get('image') else 'contain',
|
||||
)
|
||||
|
||||
# 递归创建所有子节点
|
||||
|
|
@ -180,6 +196,11 @@ def create_mindmap(request):
|
|||
children_count=0,
|
||||
depth=0,
|
||||
deleted=False,
|
||||
# 图片字段默认为空
|
||||
image_url=None,
|
||||
image_width=None,
|
||||
image_height=None,
|
||||
image_fit='contain',
|
||||
)
|
||||
|
||||
# 返回完整数据
|
||||
|
|
@ -205,6 +226,11 @@ def create_nodes_recursively(nodes_data, mindmap, parent_id):
|
|||
children_count=len(node_data.get('children', [])),
|
||||
depth=1, # 可以根据实际层级计算
|
||||
deleted=False,
|
||||
# 添加图片信息
|
||||
image_url=node_data.get('image', {}).get('url') if node_data.get('image') else None,
|
||||
image_width=node_data.get('image', {}).get('width') if node_data.get('image') else None,
|
||||
image_height=node_data.get('image', {}).get('height') if node_data.get('image') else None,
|
||||
image_fit=node_data.get('image', {}).get('fit') if node_data.get('image') else 'contain',
|
||||
)
|
||||
|
||||
# 递归创建子节点
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-a09f7810.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-5b39da23.css">
|
||||
<script type="module" crossorigin src="/assets/index-7802977e.js"></script>
|
||||
<link rel="stylesheet" href="/assets/index-3cdbb183.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -292,7 +292,27 @@ const generateMarkdownFromFile = async () => {
|
|||
markdownContent.value = '';
|
||||
|
||||
// 调用流式AI API生成Markdown
|
||||
const systemPrompt = '你是一个专业的文档分析专家。请分析上传的文档内容,生成结构化的Markdown格式思维导图。要求:1. 提取主要主题和关键概念 2. 组织成层次分明的结构 3. 使用清晰的标题和子标题 4. 保持内容的逻辑性和完整性';
|
||||
const systemPrompt = `你是一个专业的文档分析专家。请分析上传的文档内容,生成结构化的Markdown格式思维导图。要求:
|
||||
1. 提取主要主题和关键概念
|
||||
2. 组织成层次分明的结构
|
||||
3. 使用清晰的标题和子标题
|
||||
4. 保持内容的逻辑性和完整性
|
||||
5. 对于长文档,请确保完整处理所有内容,不要截断
|
||||
6. **重要:如果原文档中包含图片,请按以下方式处理:
|
||||
- 识别图片在文档中的位置和上下文
|
||||
- 根据图片内容生成准确的描述文字
|
||||
- 在相应位置插入图片占位符:
|
||||
- 图片描述要准确反映图片内容,图片路径可以是相对路径或占位符
|
||||
- 确保图片占位符放在逻辑上合适的位置**
|
||||
7. **重要:如果原文档中包含表格,请完整保留表格结构:
|
||||
- 保持表格的Markdown格式
|
||||
- 确保所有表格行都被包含
|
||||
- 不要省略任何表格内容**
|
||||
8. **重要:确保内容完整性:
|
||||
- 不要截断任何内容
|
||||
- 保持原文的完整性
|
||||
- 所有重要信息都要包含在思维导图中**
|
||||
9. 输出格式:直接输出Markdown内容,不要添加任何说明文字或代码块标记`;
|
||||
const userPrompt = `请分析以下文档内容并生成结构化Markdown:\n\n${fileContent}`;
|
||||
|
||||
// 关键修改:使用流式API而不是非流式API
|
||||
|
|
@ -464,6 +484,55 @@ const generateMarkdown = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
// 清理AI返回的内容,移除多余的说明文字
|
||||
const cleanAIResponse = (content) => {
|
||||
if (!content) return content;
|
||||
|
||||
let cleaned = content
|
||||
// 移除常见的AI开头说明文字 - 更全面的匹配
|
||||
.replace(/^好的[,,]?作为.*?专家[,,]?我已.*?分析.*?内容.*?生成了以下.*?思维导图[::]\s*/i, '')
|
||||
.replace(/^好的[,,]?作为.*?专业的.*?专家[,,]?我已.*?分析.*?内容.*?生成了以下.*?思维导图[::]\s*/i, '')
|
||||
.replace(/^以下是.*?结构化的.*?思维导图[::]\s*/i, '')
|
||||
.replace(/^以下是.*?Markdown.*?思维导图[::]\s*/i, '')
|
||||
.replace(/^以下是.*?Markdown.*?格式.*?思维导图[::]\s*/i, '')
|
||||
// 移除代码块标记
|
||||
.replace(/^```(?:markdown)?\s*/gm, '')
|
||||
.replace(/```\s*$/gm, '')
|
||||
// 移除多余的引号和标记 - 更精确的匹配
|
||||
.replace(/^「」`markdown\s*/gm, '')
|
||||
.replace(/^「」`\s*/gm, '')
|
||||
.replace(/^\s*「」`markdown\s*/gm, '')
|
||||
.replace(/^\s*「」`\s*/gm, '')
|
||||
// 移除其他可能的AI开头模式
|
||||
.replace(/^作为.*?专家[,,]?我已.*?分析.*?内容.*?生成了以下.*?思维导图[::]\s*/i, '')
|
||||
.replace(/^我已.*?分析.*?内容.*?生成了以下.*?思维导图[::]\s*/i, '')
|
||||
.replace(/^根据.*?文档内容.*?生成了以下.*?思维导图[::]\s*/i, '')
|
||||
.replace(/^基于.*?文档.*?生成了以下.*?思维导图[::]\s*/i, '')
|
||||
// 移除更多可能的开头模式
|
||||
.replace(/^以下是.*?分析.*?结果[::]\s*/i, '')
|
||||
.replace(/^以下是.*?整理.*?结果[::]\s*/i, '')
|
||||
.replace(/^以下是.*?结构化.*?内容[::]\s*/i, '')
|
||||
// 移除多余的换行
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
// 清理开头和结尾的空白
|
||||
.trim();
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
// 防抖函数
|
||||
const debounce = (func, wait) => {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
};
|
||||
|
||||
// 调用AI API生成Markdown
|
||||
// 流式AI API调用
|
||||
const callAIStreamAPI = async (systemPrompt, userPrompt) => {
|
||||
|
|
@ -497,7 +566,14 @@ Level 4 标题用 #####
|
|||
|
||||
保持段落和换行不变。
|
||||
|
||||
输出格式: 输出必须是纯Markdown格式的文本,不得包含任何额外说明、JSON或非Markdown元素。确保输出与示例风格一致。`;
|
||||
**重要:如果原文档中包含图片,请按以下方式处理:
|
||||
1. 识别图片在文档中的位置和上下文
|
||||
2. 根据图片内容生成准确的描述文字
|
||||
3. 在相应位置插入图片占位符:
|
||||
4. 图片描述要准确反映图片内容,图片路径可以是相对路径或占位符
|
||||
5. 确保图片占位符放在逻辑上合适的位置**
|
||||
|
||||
输出格式: 输出必须是纯Markdown格式的文本,不得包含任何额外说明、JSON或非Markdown元素。确保输出与示例风格一致。直接输出Markdown内容,不要添加任何说明文字。`;
|
||||
|
||||
const finalSystemPrompt = systemPrompt || defaultSystemPrompt;
|
||||
const finalUserPrompt = userPrompt || `请将以下内容转换为结构化的Markdown格式:`;
|
||||
|
|
@ -548,9 +624,12 @@ Level 4 标题用 #####
|
|||
// 实时更新Markdown内容
|
||||
markdownContent.value += data.content;
|
||||
|
||||
// 实时转换为JSON并更新显示
|
||||
// 优化:减少实时更新频率,每5个chunk更新一次
|
||||
if (chunkCount % 5 === 0) {
|
||||
try {
|
||||
const tempJSON = markdownToJSON(markdownContent.value);
|
||||
// 清理AI返回的多余内容
|
||||
const cleanedContent = cleanAIResponse(markdownContent.value);
|
||||
const tempJSON = markdownToJSON(cleanedContent);
|
||||
convertedJSON.value = JSON.stringify(tempJSON, null, 2);
|
||||
|
||||
// 🎯 关键:实时更新思维导图显示
|
||||
|
|
@ -566,10 +645,36 @@ Level 4 标题用 #####
|
|||
} catch (e) {
|
||||
// 忽略转换错误,继续接收数据
|
||||
console.warn('⚠️ 实时转换JSON失败:', e);
|
||||
console.warn('⚠️ 当前Markdown内容:', markdownContent.value);
|
||||
}
|
||||
}
|
||||
} else if (data.type === 'end') {
|
||||
showNotification('AI内容生成完成!', 'success');
|
||||
|
||||
// 最终处理:确保所有内容都被正确处理
|
||||
try {
|
||||
const finalCleanedContent = cleanAIResponse(markdownContent.value);
|
||||
console.log('🎯 最终内容长度:', finalCleanedContent.length);
|
||||
console.log('🎯 最终内容预览:', finalCleanedContent.substring(0, 500) + '...');
|
||||
|
||||
// 统计图片数量
|
||||
const allImages = extractImageFromContent(finalCleanedContent);
|
||||
console.log(`🖼️ 最终统计:共发现 ${allImages.length} 张图片`);
|
||||
|
||||
const finalJSON = markdownToJSON(finalCleanedContent);
|
||||
convertedJSON.value = JSON.stringify(finalJSON, null, 2);
|
||||
|
||||
// 最终更新思维导图
|
||||
window.dispatchEvent(new CustomEvent('realtime-mindmap-update', {
|
||||
detail: {
|
||||
data: finalJSON,
|
||||
title: finalJSON.topic || 'AI生成完成',
|
||||
source: 'ai-final',
|
||||
chunkCount: chunkCount
|
||||
}
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('⚠️ 最终处理失败:', e);
|
||||
}
|
||||
} else if (data.type === 'error') {
|
||||
throw new Error(data.content);
|
||||
}
|
||||
|
|
@ -616,7 +721,14 @@ Level 4 标题用 #####
|
|||
|
||||
保持段落和换行不变。
|
||||
|
||||
输出格式: 输出必须是纯Markdown格式的文本,不得包含任何额外说明、JSON或非Markdown元素。确保输出与示例风格一致。`;
|
||||
**重要:如果原文档中包含图片,请按以下方式处理:
|
||||
1. 识别图片在文档中的位置和上下文
|
||||
2. 根据图片内容生成准确的描述文字
|
||||
3. 在相应位置插入图片占位符:
|
||||
4. 图片描述要准确反映图片内容,图片路径可以是相对路径或占位符
|
||||
5. 确保图片占位符放在逻辑上合适的位置**
|
||||
|
||||
输出格式: 输出必须是纯Markdown格式的文本,不得包含任何额外说明、JSON或非Markdown元素。确保输出与示例风格一致。直接输出Markdown内容,不要添加任何说明文字。`;
|
||||
|
||||
// 如果没有提供系统提示词,使用默认的
|
||||
const finalSystemPrompt = systemPrompt || defaultSystemPrompt;
|
||||
|
|
@ -631,7 +743,7 @@ Level 4 标题用 #####
|
|||
|
||||
// 添加超时处理 - 增加超时时间,处理复杂文档
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000); // 减少到1分钟超时
|
||||
const timeoutId = setTimeout(() => controller.abort(), 300000); // 增加到5分钟超时
|
||||
|
||||
const response = await fetch('http://127.0.0.1:8000/api/ai/generate-markdown', {
|
||||
method: 'POST',
|
||||
|
|
@ -732,6 +844,8 @@ const formatMarkdownToText = (markdown) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 图片现在通过MindElixir原生image属性处理,不需要特殊处理
|
||||
|
||||
return markdown
|
||||
// 处理标题
|
||||
.replace(/^### (.*$)/gim, '📋 $1') // 三级标题
|
||||
|
|
@ -759,9 +873,22 @@ const formatMarkdownToText = (markdown) => {
|
|||
.replace(/`(.*?)`/g, '「$1」')
|
||||
// 处理链接
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '🔗 $1')
|
||||
// 处理换行
|
||||
// 图片通过MindElixir原生image属性处理,保留图片占位符用于后续处理
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '[图片: $1]')
|
||||
// 处理换行 - 优化换行处理,避免错位
|
||||
.replace(/\n\n/g, '\n')
|
||||
.replace(/\n/g, '\n ');
|
||||
.replace(/\n/g, '\n ')
|
||||
// 限制单行长度,避免节点过宽导致错位
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
// 如果行太长,进行智能截断
|
||||
if (line.length > 80) {
|
||||
return line.substring(0, 77) + '...';
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join('\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
// Markdown转JSON
|
||||
|
|
@ -773,7 +900,9 @@ const convertToJSON = async () => {
|
|||
|
||||
isConverting.value = true;
|
||||
try {
|
||||
const json = markdownToJSON(markdownContent.value);
|
||||
// 清理AI返回的多余内容
|
||||
const cleanedContent = cleanAIResponse(markdownContent.value);
|
||||
const json = markdownToJSON(cleanedContent);
|
||||
convertedJSON.value = JSON.stringify(json, null, 2);
|
||||
} catch (error) {
|
||||
console.error('转换失败:', error);
|
||||
|
|
@ -795,6 +924,29 @@ const countNodes = (node) => {
|
|||
return count;
|
||||
};
|
||||
|
||||
// 提取图片信息的辅助函数
|
||||
const extractImageFromContent = (content) => {
|
||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const images = [];
|
||||
let match;
|
||||
|
||||
while ((match = imageRegex.exec(content)) !== null) {
|
||||
images.push({
|
||||
alt: match[1] || '',
|
||||
url: match[2],
|
||||
fullMatch: match[0]
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🔍 从内容中提取到 ${images.length} 张图片:`, images);
|
||||
return images;
|
||||
};
|
||||
|
||||
// 从内容中移除图片Markdown语法
|
||||
const removeImageMarkdown = (content) => {
|
||||
return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '').trim();
|
||||
};
|
||||
|
||||
// Markdown转JSON的核心逻辑 - 智能层次化版本
|
||||
const markdownToJSON = (markdown) => {
|
||||
const lines = markdown.split('\n');
|
||||
|
|
@ -823,8 +975,13 @@ const markdownToJSON = (markdown) => {
|
|||
const level = match[1].length;
|
||||
const title = match[2].trim();
|
||||
|
||||
// 清理标题中的Markdown语法
|
||||
const cleanTitle = formatMarkdownToText(title);
|
||||
// 检查标题中是否包含图片
|
||||
const titleImages = extractImageFromContent(title);
|
||||
const cleanTitle = removeImageMarkdown(formatMarkdownToText(title));
|
||||
|
||||
if (titleImages.length > 0) {
|
||||
console.log(`🖼️ 在标题中发现 ${titleImages.length} 张图片: ${title}`);
|
||||
}
|
||||
|
||||
// 创建节点
|
||||
const node = {
|
||||
|
|
@ -835,6 +992,17 @@ const markdownToJSON = (markdown) => {
|
|||
data: {},
|
||||
};
|
||||
|
||||
// 如果标题中有图片,使用第一个图片作为节点图片
|
||||
if (titleImages.length > 0) {
|
||||
node.image = {
|
||||
url: titleImages[0].url,
|
||||
width: 120,
|
||||
height: 80,
|
||||
fit: 'contain'
|
||||
};
|
||||
console.log(`🖼️ 为标题节点设置图片: ${titleImages[0].url}`);
|
||||
}
|
||||
|
||||
// 如果是第一个节点(最高级别),设为根节点
|
||||
if (level === 1 && !root) {
|
||||
root = node;
|
||||
|
|
@ -888,7 +1056,42 @@ const markdownToJSON = (markdown) => {
|
|||
|
||||
// 智能处理内容,检测是否需要创建子节点
|
||||
const processContentIntelligently = (content, parentNode, nodeCounter) => {
|
||||
// 首先检查整个内容是否是表格
|
||||
// 首先检查是否包含图片内容
|
||||
const images = extractImageFromContent(content);
|
||||
if (images.length > 0) {
|
||||
console.log(`🖼️ 在内容中发现 ${images.length} 张图片`);
|
||||
|
||||
// 为每个图片创建单独的节点
|
||||
images.forEach((image, index) => {
|
||||
const imageNode = {
|
||||
id: `node_${nodeCounter++}`,
|
||||
topic: image.alt || `图片 ${index + 1}`,
|
||||
children: [],
|
||||
level: (parentNode.level || 0) + 1,
|
||||
image: {
|
||||
url: image.url,
|
||||
width: 120,
|
||||
height: 80,
|
||||
fit: 'contain'
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
parentNode.children.push(imageNode);
|
||||
console.log(`🖼️ 创建图片节点: ${imageNode.topic} - ${image.url}`);
|
||||
});
|
||||
|
||||
// 移除图片后的内容
|
||||
const contentWithoutImages = removeImageMarkdown(content);
|
||||
if (contentWithoutImages.trim()) {
|
||||
// 递归处理剩余内容
|
||||
const processedContent = processContentIntelligently(contentWithoutImages, parentNode, nodeCounter);
|
||||
nodeCounter = processedContent.nodeCounter;
|
||||
}
|
||||
|
||||
return { nodeCounter };
|
||||
}
|
||||
|
||||
// 然后检查整个内容是否是表格
|
||||
if (hasTableContent(content)) {
|
||||
console.log('🎯 检测到表格内容,创建表格节点');
|
||||
const tableNode = {
|
||||
|
|
@ -1005,6 +1208,7 @@ const processContentIntelligently = (content, parentNode, nodeCounter) => {
|
|||
return { nodeCounter: currentNodeCounter };
|
||||
};
|
||||
|
||||
|
||||
// 检测内容是否包含表格
|
||||
const hasTableContent = (content) => {
|
||||
if (!content || typeof content !== 'string') {
|
||||
|
|
|
|||
|
|
@ -367,15 +367,11 @@ const loadMindmapData = async (data, keepPosition = false, shouldCenterRoot = tr
|
|||
maxScale: 5,
|
||||
minScale: 0.1,
|
||||
markdown: (text, nodeObj) => {
|
||||
console.log('🔍 Mind Elixir markdown函数被调用:', text.substring(0, 100) + '...');
|
||||
// 直接检查内容是否包含表格或其他markdown语法
|
||||
if (text.includes('|') || text.includes('**') || text.includes('`') || text.includes('#')) {
|
||||
console.log('🎨 检测到markdown内容,开始渲染:', text.substring(0, 100) + '...');
|
||||
// 检查内容是否包含markdown语法(包括图片)
|
||||
if (text.includes('|') || text.includes('**') || text.includes('`') || text.includes('#') || text.includes('![')) {
|
||||
const result = smartRenderNodeContent(text);
|
||||
console.log('🎨 渲染结果:', result.substring(0, 200) + '...');
|
||||
return result;
|
||||
}
|
||||
console.log('🔍 内容不包含markdown语法,返回原文本');
|
||||
return text;
|
||||
}
|
||||
});
|
||||
|
|
@ -422,8 +418,8 @@ const loadMindmapData = async (data, keepPosition = false, shouldCenterRoot = tr
|
|||
maxScale: 5,
|
||||
minScale: 0.1,
|
||||
markdown: (text, nodeObj) => {
|
||||
// 直接检查内容是否包含表格或其他markdown语法
|
||||
if (text.includes('|') || text.includes('**') || text.includes('`') || text.includes('#')) {
|
||||
// 检查内容是否包含markdown语法(包括图片)
|
||||
if (text.includes('|') || text.includes('**') || text.includes('`') || text.includes('#') || text.includes('![')) {
|
||||
return smartRenderNodeContent(text);
|
||||
}
|
||||
return text;
|
||||
|
|
@ -484,8 +480,8 @@ const loadMindmapData = async (data, keepPosition = false, shouldCenterRoot = tr
|
|||
maxScale: 5,
|
||||
minScale: 0.1,
|
||||
markdown: (text, nodeObj) => {
|
||||
// 直接检查内容是否包含表格或其他markdown语法
|
||||
if (text.includes('|') || text.includes('**') || text.includes('`') || text.includes('#')) {
|
||||
// 检查内容是否包含markdown语法(包括图片)
|
||||
if (text.includes('|') || text.includes('**') || text.includes('`') || text.includes('#') || text.includes('![')) {
|
||||
return smartRenderNodeContent(text);
|
||||
}
|
||||
return text;
|
||||
|
|
@ -3494,15 +3490,11 @@ const updateMindMapRealtime = async (data, title) => {
|
|||
maxScale: 5,
|
||||
minScale: 0.1,
|
||||
markdown: (text, nodeObj) => {
|
||||
// console.log('🔍 实时更新 Mind Elixir markdown函数被调用:', text.substring(0, 100) + '...');
|
||||
// 直接检查内容是否包含表格、数学公式或其他markdown语法
|
||||
if (text.includes('|') || text.includes('**') || text.includes('`') || text.includes('#') || text.includes('$')) {
|
||||
console.log('🎨 实时更新 检测到markdown内容,开始渲染:', text.substring(0, 100) + '...');
|
||||
// 检查内容是否包含markdown语法(包括图片和数学公式)
|
||||
if (text.includes('|') || text.includes('**') || text.includes('`') || text.includes('#') || text.includes('$') || text.includes('![')) {
|
||||
const result = smartRenderNodeContent(text);
|
||||
console.log('🎨 实时更新 渲染结果:', result.substring(0, 200) + '...');
|
||||
return result;
|
||||
}
|
||||
console.log('🔍 实时更新 内容不包含markdown语法,返回原文本');
|
||||
return text;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Topic, Wrapper, Parent, Children, Expander } from '../types/dom'
|
|||
import type { MindElixirInstance, NodeObj } from '../types/index'
|
||||
import { encodeHTML } from '../utils/index'
|
||||
import { layoutChildren } from './layout'
|
||||
// 移除imageProcessor引用,使用MindElixir原生image属性
|
||||
|
||||
// DOM manipulation
|
||||
const $d = document
|
||||
|
|
@ -55,7 +56,8 @@ export const shapeTpc = function (this: MindElixirInstance, tpc: Topic, nodeObj:
|
|||
if (this.markdown) {
|
||||
textEl.innerHTML = this.markdown(nodeObj.topic, nodeObj)
|
||||
} else {
|
||||
textEl.textContent = nodeObj.topic
|
||||
// 直接设置文本内容,图片通过MindElixir原生image属性处理
|
||||
textEl.innerHTML = nodeObj.topic || ''
|
||||
}
|
||||
|
||||
tpc.appendChild(textEl)
|
||||
|
|
|
|||
|
|
@ -14,12 +14,16 @@ import 'prismjs/components/prism-python';
|
|||
import 'prismjs/components/prism-sql';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
// 自定义渲染器(移除图片处理,使用MindElixir原生image属性)
|
||||
const renderer = new marked.Renderer();
|
||||
|
||||
// 配置marked选项
|
||||
marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true, // GitHub Flavored Markdown
|
||||
tables: true, // 支持表格
|
||||
sanitize: false, // 允许HTML(用于数学公式等)
|
||||
renderer: renderer // 使用自定义渲染器
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -40,7 +44,9 @@ export const renderMarkdownToHTML = (markdown) => {
|
|||
const html = marked.parse(processedMarkdown);
|
||||
|
||||
// 后处理HTML
|
||||
return postprocessHTML(html);
|
||||
const finalHTML = postprocessHTML(html);
|
||||
|
||||
return finalHTML;
|
||||
} catch (error) {
|
||||
console.error('Markdown渲染失败:', error);
|
||||
return `<div class="markdown-error">渲染失败: ${error.message}</div>`;
|
||||
|
|
@ -166,6 +172,11 @@ const addMarkdownStyles = (container) => {
|
|||
.markdown-content {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
|
|
@ -196,12 +207,15 @@ const addMarkdownStyles = (container) => {
|
|||
.markdown-content ol {
|
||||
margin: 2px 0;
|
||||
padding-left: 16px;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin: 1px 0;
|
||||
line-height: 1.3;
|
||||
color: #666;
|
||||
display: list-item;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content strong,
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
dist
|
||||
src/__tests__
|
||||
index.html
|
||||
test.html
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
|
||||
plugins: ['@typescript-eslint'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: [ssshooter]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: ssshooter
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages
|
||||
|
||||
name: Node.js Package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm i
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.npm_token}}
|
||||
|
||||
- name: Checkout docs repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: mind-elixir/docs
|
||||
token: ${{ secrets.PAT }}
|
||||
path: me-docs
|
||||
|
||||
- name: Generate API documentation
|
||||
run: |
|
||||
npm run doc
|
||||
npm run doc:md
|
||||
|
||||
- name: Copy build results to docs repository
|
||||
run: |
|
||||
cp -r ./md/* ./me-docs/docs/api
|
||||
cp -r ./md/* ./me-docs/i18n/ja/docusaurus-plugin-content-docs/current/api
|
||||
cp -r ./md/* ./me-docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/api
|
||||
|
||||
- name: Push changes to docs repository
|
||||
run: |
|
||||
cd me-docs
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add .
|
||||
if git diff-index --quiet HEAD; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "Update API documentation"
|
||||
git push
|
||||
fi
|
||||
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
dist
|
||||
doc
|
||||
.nyc_output
|
||||
coverage
|
||||
api
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.eslintcache
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
/md
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx --no -- commitlint --edit ${1}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npm run test
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Typescript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# gatsby files
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Mac files
|
||||
.DS_Store
|
||||
|
||||
# Yarn
|
||||
yarn-error.log
|
||||
.pnp/
|
||||
.pnp.js
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
src/
|
||||
webpack.config.js
|
||||
out/
|
||||
doc/
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 150,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2019 DjZhou
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -1,396 +0,0 @@
|
|||
/**
|
||||
* Config file for API Extractor. For more info, please visit: https://api-extractor.com
|
||||
*/
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
|
||||
/**
|
||||
* Optionally specifies another JSON config file that this file extends from. This provides a way for
|
||||
* standard settings to be shared across multiple projects.
|
||||
*
|
||||
* If the path starts with "./" or "../", the path is resolved relative to the folder of the file that contains
|
||||
* the "extends" field. Otherwise, the first path segment is interpreted as an NPM package name, and will be
|
||||
* resolved using NodeJS require().
|
||||
*
|
||||
* SUPPORTED TOKENS: none
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "extends": "./shared/api-extractor-base.json"
|
||||
// "extends": "my-package/include/api-extractor-base.json"
|
||||
/**
|
||||
* Determines the "<projectFolder>" token that can be used with other config file settings. The project folder
|
||||
* typically contains the tsconfig.json and package.json config files, but the path is user-defined.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting.
|
||||
*
|
||||
* The default value for "projectFolder" is the token "<lookup>", which means the folder is determined by traversing
|
||||
* parent folders, starting from the folder containing api-extractor.json, and stopping at the first folder
|
||||
* that contains a tsconfig.json file. If a tsconfig.json file cannot be found in this way, then an error
|
||||
* will be reported.
|
||||
*
|
||||
* SUPPORTED TOKENS: <lookup>
|
||||
* DEFAULT VALUE: "<lookup>"
|
||||
*/
|
||||
// "projectFolder": "..",
|
||||
/**
|
||||
* (REQUIRED) Specifies the .d.ts file to be used as the starting point for analysis. API Extractor
|
||||
* analyzes the symbols exported by this module.
|
||||
*
|
||||
* The file extension must be ".d.ts" and not ".ts".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
*/
|
||||
"mainEntryPointFilePath": "<projectFolder>/dist/types/docs.d.ts",
|
||||
/**
|
||||
* A list of NPM package names whose exports should be treated as part of this package.
|
||||
*
|
||||
* For example, suppose that Webpack is used to generate a distributed bundle for the project "library1",
|
||||
* and another NPM package "library2" is embedded in this bundle. Some types from library2 may become part
|
||||
* of the exported API for library1, but by default API Extractor would generate a .d.ts rollup that explicitly
|
||||
* imports library2. To avoid this, we can specify:
|
||||
*
|
||||
* "bundledPackages": [ "library2" ],
|
||||
*
|
||||
* This would direct API Extractor to embed those types directly in the .d.ts rollup, as if they had been
|
||||
* local files for library1.
|
||||
*/
|
||||
"bundledPackages": [],
|
||||
/**
|
||||
* Specifies what type of newlines API Extractor should use when writing output files. By default, the output files
|
||||
* will be written with Windows-style newlines. To use POSIX-style newlines, specify "lf" instead.
|
||||
* To use the OS's default newline kind, specify "os".
|
||||
*
|
||||
* DEFAULT VALUE: "crlf"
|
||||
*/
|
||||
// "newlineKind": "crlf",
|
||||
/**
|
||||
* Set to true when invoking API Extractor's test harness. When `testMode` is true, the `toolVersion` field in the
|
||||
* .api.json file is assigned an empty string to prevent spurious diffs in output files tracked for tests.
|
||||
*
|
||||
* DEFAULT VALUE: "false"
|
||||
*/
|
||||
// "testMode": false,
|
||||
/**
|
||||
* Specifies how API Extractor sorts members of an enum when generating the .api.json file. By default, the output
|
||||
* files will be sorted alphabetically, which is "by-name". To keep the ordering in the source code, specify
|
||||
* "preserve".
|
||||
*
|
||||
* DEFAULT VALUE: "by-name"
|
||||
*/
|
||||
// "enumMemberOrder": "by-name",
|
||||
/**
|
||||
* Determines how the TypeScript compiler engine will be invoked by API Extractor.
|
||||
*/
|
||||
"compiler": {
|
||||
/**
|
||||
* Specifies the path to the tsconfig.json file to be used by API Extractor when analyzing the project.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* Note: This setting will be ignored if "overrideTsconfig" is used.
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/tsconfig.json"
|
||||
*/
|
||||
// "tsconfigFilePath": "<projectFolder>/tsconfig.json",
|
||||
/**
|
||||
* Provides a compiler configuration that will be used instead of reading the tsconfig.json file from disk.
|
||||
* The object must conform to the TypeScript tsconfig schema:
|
||||
*
|
||||
* http://json.schemastore.org/tsconfig
|
||||
*
|
||||
* If omitted, then the tsconfig.json file will be read from the "projectFolder".
|
||||
*
|
||||
* DEFAULT VALUE: no overrideTsconfig section
|
||||
*/
|
||||
// "overrideTsconfig": {
|
||||
// . . .
|
||||
// }
|
||||
/**
|
||||
* This option causes the compiler to be invoked with the --skipLibCheck option. This option is not recommended
|
||||
* and may cause API Extractor to produce incomplete or incorrect declarations, but it may be required when
|
||||
* dependencies contain declarations that are incompatible with the TypeScript engine that API Extractor uses
|
||||
* for its analysis. Where possible, the underlying issue should be fixed rather than relying on skipLibCheck.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "skipLibCheck": true,
|
||||
},
|
||||
/**
|
||||
* Configures how the API report file (*.api.md) will be generated.
|
||||
*/
|
||||
"apiReport": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate an API report.
|
||||
*/
|
||||
"enabled": true,
|
||||
/**
|
||||
* The filename for the API report files. It will be combined with "reportFolder" or "reportTempFolder" to produce
|
||||
* a full file path.
|
||||
*
|
||||
* The file extension should be ".api.md", and the string should not contain a path separator such as "\" or "/".
|
||||
*
|
||||
* SUPPORTED TOKENS: <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<unscopedPackageName>.api.md"
|
||||
*/
|
||||
// "reportFileName": "<unscopedPackageName>.api.md",
|
||||
/**
|
||||
* Specifies the folder where the API report file is written. The file name portion is determined by
|
||||
* the "reportFileName" setting.
|
||||
*
|
||||
* The API report file is normally tracked by Git. Changes to it can be used to trigger a branch policy,
|
||||
* e.g. for an API review.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/"
|
||||
*/
|
||||
"reportFolder": "<projectFolder>/api/",
|
||||
/**
|
||||
* Specifies the folder where the temporary report file is written. The file name portion is determined by
|
||||
* the "reportFileName" setting.
|
||||
*
|
||||
* After the temporary file is written to disk, it is compared with the file in the "reportFolder".
|
||||
* If they are different, a production build will fail.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/"
|
||||
*/
|
||||
"reportTempFolder": "<projectFolder>/api/",
|
||||
/**
|
||||
* Whether "forgotten exports" should be included in the API report file. Forgotten exports are declarations
|
||||
* flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to
|
||||
* learn more.
|
||||
*
|
||||
* DEFAULT VALUE: "false"
|
||||
*/
|
||||
"includeForgottenExports": false
|
||||
},
|
||||
/**
|
||||
* Configures how the doc model file (*.api.json) will be generated.
|
||||
*/
|
||||
"docModel": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate a doc model file.
|
||||
*/
|
||||
"enabled": true,
|
||||
/**
|
||||
* The output path for the doc model file. The file extension should be ".api.json".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/temp/<unscopedPackageName>.api.json"
|
||||
*/
|
||||
"apiJsonFilePath": "<projectFolder>/api/<unscopedPackageName>.api.json"
|
||||
/**
|
||||
* Whether "forgotten exports" should be included in the doc model file. Forgotten exports are declarations
|
||||
* flagged with `ae-forgotten-export` warnings. See https://api-extractor.com/pages/messages/ae-forgotten-export/ to
|
||||
* learn more.
|
||||
*
|
||||
* DEFAULT VALUE: "false"
|
||||
*/
|
||||
// "includeForgottenExports": false,
|
||||
/**
|
||||
* The base URL where the project's source code can be viewed on a website such as GitHub or
|
||||
* Azure DevOps. This URL path corresponds to the `<projectFolder>` path on disk.
|
||||
*
|
||||
* This URL is concatenated with the file paths serialized to the doc model to produce URL file paths to individual API items.
|
||||
* For example, if the `projectFolderUrl` is "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor" and an API
|
||||
* item's file path is "api/ExtractorConfig.ts", the full URL file path would be
|
||||
* "https://github.com/microsoft/rushstack/tree/main/apps/api-extractor/api/ExtractorConfig.js".
|
||||
*
|
||||
* Can be omitted if you don't need source code links in your API documentation reference.
|
||||
*
|
||||
* SUPPORTED TOKENS: none
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "projectFolderUrl": "http://github.com/path/to/your/projectFolder"
|
||||
},
|
||||
/**
|
||||
* Configures how the .d.ts rollup file will be generated.
|
||||
*/
|
||||
"dtsRollup": {
|
||||
/**
|
||||
* (REQUIRED) Whether to generate the .d.ts rollup file.
|
||||
*/
|
||||
"enabled": true
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated without any trimming.
|
||||
* This file will include all declarations that are exported by the main entry point.
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<projectFolder>/dist/<unscopedPackageName>.d.ts"
|
||||
*/
|
||||
// "untrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>.d.ts",
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for an "alpha" release.
|
||||
* This file will include only declarations that are marked as "@public", "@beta", or "@alpha".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "alphaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-alpha.d.ts",
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "beta" release.
|
||||
* This file will include only declarations that are marked as "@public" or "@beta".
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "betaTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-beta.d.ts",
|
||||
/**
|
||||
* Specifies the output path for a .d.ts rollup file to be generated with trimming for a "public" release.
|
||||
* This file will include only declarations that are marked as "@public".
|
||||
*
|
||||
* If the path is an empty string, then this file will not be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: ""
|
||||
*/
|
||||
// "publicTrimmedFilePath": "<projectFolder>/dist/<unscopedPackageName>-public.d.ts",
|
||||
/**
|
||||
* When a declaration is trimmed, by default it will be replaced by a code comment such as
|
||||
* "Excluded from this release type: exampleMember". Set "omitTrimmingComments" to true to remove the
|
||||
* declaration completely.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "omitTrimmingComments": true
|
||||
},
|
||||
/**
|
||||
* Configures how the tsdoc-metadata.json file will be generated.
|
||||
*/
|
||||
"tsdocMetadata": {
|
||||
/**
|
||||
* Whether to generate the tsdoc-metadata.json file.
|
||||
*
|
||||
* DEFAULT VALUE: true
|
||||
*/
|
||||
// "enabled": true,
|
||||
/**
|
||||
* Specifies where the TSDoc metadata file should be written.
|
||||
*
|
||||
* The path is resolved relative to the folder of the config file that contains the setting; to change this,
|
||||
* prepend a folder token such as "<projectFolder>".
|
||||
*
|
||||
* The default value is "<lookup>", which causes the path to be automatically inferred from the "tsdocMetadata",
|
||||
* "typings" or "main" fields of the project's package.json. If none of these fields are set, the lookup
|
||||
* falls back to "tsdoc-metadata.json" in the package folder.
|
||||
*
|
||||
* SUPPORTED TOKENS: <projectFolder>, <packageName>, <unscopedPackageName>
|
||||
* DEFAULT VALUE: "<lookup>"
|
||||
*/
|
||||
// "tsdocMetadataFilePath": "<projectFolder>/dist/tsdoc-metadata.json"
|
||||
},
|
||||
/**
|
||||
* Configures how API Extractor reports error and warning messages produced during analysis.
|
||||
*
|
||||
* There are three sources of messages: compiler messages, API Extractor messages, and TSDoc messages.
|
||||
*/
|
||||
"messages": {
|
||||
/**
|
||||
* Configures handling of diagnostic messages reported by the TypeScript compiler engine while analyzing
|
||||
* the input .d.ts files.
|
||||
*
|
||||
* TypeScript message identifiers start with "TS" followed by an integer. For example: "TS2551"
|
||||
*
|
||||
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
|
||||
*/
|
||||
"compilerMessageReporting": {
|
||||
/**
|
||||
* Configures the default routing for messages that don't match an explicit rule in this table.
|
||||
*/
|
||||
"default": {
|
||||
/**
|
||||
* Specifies whether the message should be written to the the tool's output log. Note that
|
||||
* the "addToApiReportFile" property may supersede this option.
|
||||
*
|
||||
* Possible values: "error", "warning", "none"
|
||||
*
|
||||
* Errors cause the build to fail and return a nonzero exit code. Warnings cause a production build fail
|
||||
* and return a nonzero exit code. For a non-production build (e.g. when "api-extractor run" includes
|
||||
* the "--local" option), the warning is displayed but the build will not fail.
|
||||
*
|
||||
* DEFAULT VALUE: "warning"
|
||||
*/
|
||||
"logLevel": "warning"
|
||||
/**
|
||||
* When addToApiReportFile is true: If API Extractor is configured to write an API report file (.api.md),
|
||||
* then the message will be written inside that file; otherwise, the message is instead logged according to
|
||||
* the "logLevel" option.
|
||||
*
|
||||
* DEFAULT VALUE: false
|
||||
*/
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
// "TS2551": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
},
|
||||
/**
|
||||
* Configures handling of messages reported by API Extractor during its analysis.
|
||||
*
|
||||
* API Extractor message identifiers start with "ae-". For example: "ae-extra-release-tag"
|
||||
*
|
||||
* DEFAULT VALUE: See api-extractor-defaults.json for the complete table of extractorMessageReporting mappings
|
||||
*/
|
||||
"extractorMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "warning"
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
// "ae-extra-release-tag": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
},
|
||||
/**
|
||||
* Configures handling of messages reported by the TSDoc parser when analyzing code comments.
|
||||
*
|
||||
* TSDoc message identifiers start with "tsdoc-". For example: "tsdoc-link-tag-unescaped-text"
|
||||
*
|
||||
* DEFAULT VALUE: A single "default" entry with logLevel=warning.
|
||||
*/
|
||||
"tsdocMessageReporting": {
|
||||
"default": {
|
||||
"logLevel": "warning"
|
||||
// "addToApiReportFile": false
|
||||
}
|
||||
// "tsdoc-link-tag-unescaped-text": {
|
||||
// "logLevel": "warning",
|
||||
// "addToApiReportFile": true
|
||||
// },
|
||||
//
|
||||
// . . .
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { fileURLToPath } from 'url'
|
||||
import { build } from 'vite'
|
||||
import strip from '@rollup/plugin-strip'
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
||||
const buildList = [
|
||||
{
|
||||
name: 'MindElixir',
|
||||
enrty: __dirname + './src/index.ts',
|
||||
},
|
||||
{
|
||||
name: 'MindElixirLite',
|
||||
enrty: __dirname + './src/index.ts',
|
||||
mode: 'lite',
|
||||
},
|
||||
{
|
||||
name: 'example',
|
||||
enrty: __dirname + './src/exampleData/1.ts',
|
||||
},
|
||||
{
|
||||
name: 'LayoutSsr',
|
||||
enrty: __dirname + './src/utils/layout-ssr.ts',
|
||||
},
|
||||
]
|
||||
for (let i = 0; i < buildList.length; i++) {
|
||||
const info = buildList[i]
|
||||
console.log(`\n\nBuilding ${info.name}...\n\n`)
|
||||
await build({
|
||||
build: {
|
||||
emptyOutDir: i === 0,
|
||||
lib: {
|
||||
entry: info.enrty,
|
||||
fileName: info.name,
|
||||
name: info.name,
|
||||
formats: ['iife', 'es'],
|
||||
},
|
||||
rollupOptions: {
|
||||
plugins: [
|
||||
strip({
|
||||
include: ['**/*.ts', '**/*.js'],
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
mode: info.mode,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
export default {
|
||||
parserPreset: 'conventional-changelog-conventionalcommits',
|
||||
rules: {
|
||||
'body-leading-blank': [1, 'always'],
|
||||
'body-max-line-length': [2, 'always', 120],
|
||||
'footer-leading-blank': [1, 'always'],
|
||||
'footer-max-line-length': [2, 'always', 100],
|
||||
'header-max-length': [2, 'always', 100],
|
||||
'subject-case': [2, 'never', ['sentence-case', 'start-case', 'pascal-case', 'upper-case']],
|
||||
'subject-empty': [2, 'never'],
|
||||
'subject-full-stop': [2, 'never', '.'],
|
||||
'type-case': [2, 'always', 'lower-case'],
|
||||
'type-empty': [2, 'never'],
|
||||
'type-enum': [2, 'always', ['build', 'chore', 'ci', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test']],
|
||||
},
|
||||
prompt: {
|
||||
questions: {
|
||||
type: {
|
||||
description: "Select the type of change that you're committing",
|
||||
enum: {
|
||||
feat: {
|
||||
description: 'A new feature',
|
||||
title: 'Features',
|
||||
emoji: '✨',
|
||||
},
|
||||
fix: {
|
||||
description: 'A bug fix',
|
||||
title: 'Bug Fixes',
|
||||
emoji: '🐛',
|
||||
},
|
||||
docs: {
|
||||
description: 'Documentation only changes',
|
||||
title: 'Documentation',
|
||||
emoji: '📚',
|
||||
},
|
||||
style: {
|
||||
description: 'Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)',
|
||||
title: 'Styles',
|
||||
emoji: '💎',
|
||||
},
|
||||
refactor: {
|
||||
description: 'A code change that neither fixes a bug nor adds a feature',
|
||||
title: 'Code Refactoring',
|
||||
emoji: '📦',
|
||||
},
|
||||
perf: {
|
||||
description: 'A code change that improves performance',
|
||||
title: 'Performance Improvements',
|
||||
emoji: '🚀',
|
||||
},
|
||||
test: {
|
||||
description: 'Adding missing tests or correcting existing tests',
|
||||
title: 'Tests',
|
||||
emoji: '🚨',
|
||||
},
|
||||
build: {
|
||||
description: 'Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)',
|
||||
title: 'Builds',
|
||||
emoji: '🛠',
|
||||
},
|
||||
ci: {
|
||||
description: 'Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)',
|
||||
title: 'Continuous Integrations',
|
||||
emoji: '⚙️',
|
||||
},
|
||||
chore: {
|
||||
description: "Other changes that don't modify src or test files",
|
||||
title: 'Chores',
|
||||
emoji: '♻️',
|
||||
},
|
||||
revert: {
|
||||
description: 'Reverts a previous commit',
|
||||
title: 'Reverts',
|
||||
emoji: '🗑',
|
||||
},
|
||||
},
|
||||
},
|
||||
scope: {
|
||||
description: 'What is the scope of this change (e.g. component or file name)',
|
||||
},
|
||||
subject: {
|
||||
description: 'Write a short, imperative tense description of the change',
|
||||
},
|
||||
body: {
|
||||
description: 'Provide a longer description of the change',
|
||||
},
|
||||
isBreaking: {
|
||||
description: 'Are there any breaking changes?',
|
||||
},
|
||||
breakingBody: {
|
||||
description: 'A BREAKING CHANGE commit requires a body. Please enter a longer description of the commit itself',
|
||||
},
|
||||
breaking: {
|
||||
description: 'Describe the breaking changes',
|
||||
},
|
||||
isIssueAffected: {
|
||||
description: 'Does this change affect any open issues?',
|
||||
},
|
||||
issuesBody: {
|
||||
description: 'If issues are closed, the commit requires a body. Please enter a longer description of the commit itself',
|
||||
},
|
||||
issues: {
|
||||
description: 'Add issue references (e.g. "fix #123", "re #123".)',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
color: black;
|
||||
background: none;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*='language-'] {
|
||||
position: relative;
|
||||
margin: 0.5em 0;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
pre[class*='language-']>code {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
code[class*='language'] {
|
||||
border-radius: 3px;
|
||||
background: #faf8f5;
|
||||
max-height: inherit;
|
||||
height: inherit;
|
||||
padding: 1em;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre)>code[class*='language-'] {
|
||||
display: inline;
|
||||
position: relative;
|
||||
color: #c92c2c;
|
||||
padding: 0.15em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.block-comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #7d8b99;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #5f6364;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.function-name,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #c92c2c;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.function,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #2f9c0a;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.token.variable {
|
||||
color: #a67f59;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword,
|
||||
.token.class-name {
|
||||
color: #1990b8;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important {
|
||||
color: #e90;
|
||||
}
|
||||
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #a67f59;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.token.important {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
|
||||
pre[class*='language-']:before,
|
||||
pre[class*='language-']:after {
|
||||
bottom: 14px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Plugin styles */
|
||||
.token.tab:not(:empty):before,
|
||||
.token.cr:before,
|
||||
.token.lf:before {
|
||||
color: #e0d7d1;
|
||||
}
|
||||
|
||||
/* Plugin styles: Line Numbers */
|
||||
pre[class*='language-'].line-numbers.line-numbers {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
pre[class*='language-'].line-numbers.line-numbers code {
|
||||
padding-left: 3.8em;
|
||||
}
|
||||
|
||||
pre[class*='language-'].line-numbers.line-numbers .line-numbers-rows {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Plugin styles: Line Highlight */
|
||||
pre[class*='language-'][data-line] {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
pre[data-line] code {
|
||||
position: relative;
|
||||
padding-left: 4em;
|
||||
}
|
||||
|
||||
pre .line-highlight {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>Mind Elixir</title>
|
||||
<link rel="stylesheet" href="./index.css">
|
||||
<style>
|
||||
/* test tailwind compatibility */
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
#map,
|
||||
#map2 {
|
||||
margin-top: 50px;
|
||||
height: 500px;
|
||||
width: 800px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<!-- test transform compatibility -->
|
||||
<div style="transform: translateX(30px) translateY(20px);">
|
||||
<div id="map2"></div>
|
||||
</div>
|
||||
<div id="ssr"></div>
|
||||
<script type="module" src="/src/dev.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
{
|
||||
"name": "mind-elixir",
|
||||
"version": "5.1.1",
|
||||
"type": "module",
|
||||
"description": "Mind elixir is a free open source mind map core.",
|
||||
"keywords": [
|
||||
"mind-elixir",
|
||||
"mindmap",
|
||||
"dom",
|
||||
"visualization"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "husky install",
|
||||
"lint": "eslint --cache --max-warnings 0 \"src/**/*.{js,json,ts}\" --fix",
|
||||
"dev": "vite",
|
||||
"build": "node build.js && tsc",
|
||||
"tsc": "tsc",
|
||||
"preview": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:clean": "rimraf .nyc_output coverage",
|
||||
"test:coverage": "pnpm test:clean && pnpm test && pnpm nyc && npx http-server ./coverage",
|
||||
"nyc": "nyc report --reporter=html",
|
||||
"doc": "api-extractor run --local --verbose",
|
||||
"doc:md": "api-documenter markdown --input-folder ./api --output-folder ./md",
|
||||
"beta": "npm run build && npm publish --tag beta"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"import": "./dist/MindElixir.js",
|
||||
"require": "./dist/MindElixir.js"
|
||||
},
|
||||
"./lite": {
|
||||
"import": "./dist/MindElixirLite.iife.js"
|
||||
},
|
||||
"./example": {
|
||||
"types": "./dist/types/exampleData/1.d.ts",
|
||||
"import": "./dist/example.js",
|
||||
"require": "./dist/example.js"
|
||||
},
|
||||
"./LayoutSsr": {
|
||||
"types": "./dist/types/utils/LayoutSsr.d.ts",
|
||||
"import": "./dist/LayoutSsr.js",
|
||||
"require": "./dist/LayoutSsr.js"
|
||||
},
|
||||
"./readme.md": "./readme.md",
|
||||
"./package.json": "./package.json",
|
||||
"./style": "./dist/style.css",
|
||||
"./style.css": "./dist/style.css"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"example": [
|
||||
"./dist/types/exampleData/1.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"main": "dist/MindElixir.js",
|
||||
"types": "dist/types/index.d.ts",
|
||||
"lint-staged": {
|
||||
"src/**/*.{ts,js}": [
|
||||
"eslint --cache --fix"
|
||||
],
|
||||
"src/**/*.{json,less}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"package.json",
|
||||
"dist"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ssshooter/mind-elixir-core"
|
||||
},
|
||||
"homepage": "https://mind-elixir.com/",
|
||||
"author": "ssshooter",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^17.8.1",
|
||||
"@commitlint/config-conventional": "^17.8.1",
|
||||
"@microsoft/api-documenter": "^7.25.21",
|
||||
"@microsoft/api-extractor": "^7.47.11",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@rollup/plugin-strip": "^3.0.4",
|
||||
"@types/node": "^20.14.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@viselect/vanilla": "3.9.0",
|
||||
"@zumer/snapdom": "^1.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"husky": "^8.0.3",
|
||||
"katex": "^0.16.22",
|
||||
"less": "^4.2.0",
|
||||
"lint-staged": "^13.3.0",
|
||||
"marked": "^16.2.0",
|
||||
"nyc": "^17.1.0",
|
||||
"prettier": "2.8.4",
|
||||
"rimraf": "^6.0.1",
|
||||
"simple-markdown-to-html": "^1.0.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^4.5.3",
|
||||
"vite-plugin-istanbul": "^7.1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
timeout: 10000,
|
||||
testDir: './tests',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://127.0.0.1:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'pnpm dev --port 23334',
|
||||
url: 'http://127.0.0.1:23334',
|
||||
reuseExistingServer: true,
|
||||
env: {
|
||||
VITE_COVERAGE: 'true'
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -1,432 +0,0 @@
|
|||
<p align="center">
|
||||
<a href="https://docs.mind-elixir.com" target="_blank" rel="noopener noreferrer">
|
||||
<img width="150" src="https://raw.githubusercontent.com/ssshooter/mind-elixir-core/master/images/logo2.png" alt="mindelixir logo2">
|
||||
</a>
|
||||
<h1 align="center">Mind Elixir</h1>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/mind-elixir">
|
||||
<img src="https://img.shields.io/npm/v/mind-elixir" alt="version">
|
||||
</a>
|
||||
<a href="https://github.com/ssshooter/mind-elixir-core/blob/master/LICENSE">
|
||||
<img src="https://img.shields.io/npm/l/mind-elixir" alt="license">
|
||||
</a>
|
||||
<a href="https://app.codacy.com/gh/ssshooter/mind-elixir-core?utm_source=github.com&utm_medium=referral&utm_content=ssshooter/mind-elixir-core&utm_campaign=Badge_Grade_Settings">
|
||||
<img src="https://api.codacy.com/project/badge/Grade/09fadec5bf094886b30cea6aabf3a88b" alt="code quality">
|
||||
</a>
|
||||
<a href="https://bundlephobia.com/result?p=mind-elixir">
|
||||
<img src="https://badgen.net/bundlephobia/dependency-count/mind-elixir" alt="dependency-count">
|
||||
</a>
|
||||
<a href="https://packagephobia.com/result?p=mind-elixir">
|
||||
<img src="https://packagephobia.com/badge?p=mind-elixir" alt="package size">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
[English](/readme.md) |
|
||||
[中文](/readme/zh.md) |
|
||||
[Español](/readme/es.md) |
|
||||
[Français](/readme/fr.md) |
|
||||
[Português](/readme/pt.md) |
|
||||
[Русский](/readme/ru.md) |
|
||||
[日本語](/readme/ja.md) |
|
||||
[한국어](/readme/ko.md)
|
||||
|
||||
Mind elixir is a open source JavaScript mind map core. You can use it with any frontend framework you like.
|
||||
|
||||
## Features
|
||||
|
||||
### 🎨 **User Experience**
|
||||
|
||||
- **Fluent UX** - Smooth and intuitive interactions
|
||||
- **Well designed** - Clean and modern interface
|
||||
- **Mobile friendly** - Touch events for mobile devices
|
||||
- **Efficient shortcuts** - Keyboard shortcuts for power users
|
||||
|
||||
### ⚡ **Performance & Architecture**
|
||||
|
||||
- **Lightweight** - Minimal bundle size
|
||||
- **High performance** - Optimized for large mind maps
|
||||
- **Framework agnostic** - Works with any frontend framework
|
||||
- **Pluginable** - Extensible architecture
|
||||
|
||||
### 🛠️ **Core Features**
|
||||
|
||||
- **Interactive editing** - Built-in drag and drop / node edit capabilities
|
||||
- **Bulk operations** - Multi-node selection and operations
|
||||
- **Undo / Redo** - Complete operation history
|
||||
- **Node connections & summarization** - Custom node linking and content summarization
|
||||
|
||||
### 📤 **Export & Customization**
|
||||
|
||||
- **Multiple export formats** - SVG / PNG / HTML export
|
||||
- **Easy styling** - Customize mindmap with CSS variables
|
||||
- **Theme support** - Built-in themes and custom styling
|
||||
|
||||
[v5 Breaking Changes](https://github.com/SSShooter/mind-elixir-core/wiki/Breaking-Change#500)
|
||||
|
||||
<details>
|
||||
<summary>Table of Contents</summary>
|
||||
|
||||
- [Features](#features)
|
||||
- [🎨 **User Experience**](#-user-experience)
|
||||
- [⚡ **Performance \& Architecture**](#-performance--architecture)
|
||||
- [🛠️ **Core Features**](#️-core-features)
|
||||
- [📤 **Export \& Customization**](#-export--customization)
|
||||
- [Try now](#try-now)
|
||||
- [Playground](#playground)
|
||||
- [Documentation](#documentation)
|
||||
- [Usage](#usage)
|
||||
- [Install](#install)
|
||||
- [NPM](#npm)
|
||||
- [Script tag](#script-tag)
|
||||
- [Init](#init)
|
||||
- [Data Structure](#data-structure)
|
||||
- [Event Handling](#event-handling)
|
||||
- [Data Export And Import](#data-export-and-import)
|
||||
- [Operation Guards](#operation-guards)
|
||||
- [Export as a Image](#export-as-a-image)
|
||||
- [Theme](#theme)
|
||||
- [Shortcuts](#shortcuts)
|
||||
- [Who's using](#whos-using)
|
||||
- [Ecosystem](#ecosystem)
|
||||
- [Development](#development)
|
||||
- [Thanks](#thanks)
|
||||
- [Contributors](#contributors)
|
||||
|
||||
</details>
|
||||
|
||||
## Try now
|
||||
|
||||

|
||||
|
||||
https://mind-elixir.com/
|
||||
|
||||
### Playground
|
||||
|
||||
- Vanilla JS - https://codepen.io/ssshooter/pen/vEOqWjE
|
||||
- React - https://codesandbox.io/p/devbox/mind-elixir-3-x-react-18-x-forked-f3mtcd
|
||||
- Vue3 - https://codesandbox.io/p/sandbox/mind-elixir-3-x-vue3-lth484
|
||||
|
||||
## Documentation
|
||||
|
||||
https://docs.mind-elixir.com/
|
||||
|
||||
## Usage
|
||||
|
||||
### Install
|
||||
|
||||
#### NPM
|
||||
|
||||
```bash
|
||||
npm i mind-elixir -S
|
||||
```
|
||||
|
||||
```javascript
|
||||
import MindElixir from 'mind-elixir'
|
||||
import 'mind-elixir/style.css'
|
||||
```
|
||||
|
||||
#### Script tag
|
||||
|
||||
```html
|
||||
<script type="module" src="https://cdn.jsdelivr.net/npm/mind-elixir/dist/MindElixir.js"></script>
|
||||
```
|
||||
|
||||
And in your CSS file:
|
||||
|
||||
```css
|
||||
@import 'https://cdn.jsdelivr.net/npm/mind-elixir/dist/style.css';
|
||||
```
|
||||
|
||||
### Init
|
||||
|
||||
```html
|
||||
<div id="map"></div>
|
||||
<style>
|
||||
#map {
|
||||
height: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
```javascript
|
||||
import MindElixir from 'mind-elixir'
|
||||
import 'mind-elixir/style.css'
|
||||
import example from 'mind-elixir/dist/example1'
|
||||
|
||||
let options = {
|
||||
el: '#map', // or HTMLDivElement
|
||||
direction: MindElixir.LEFT,
|
||||
draggable: true, // default true
|
||||
toolBar: true, // default true
|
||||
nodeMenu: true, // default true
|
||||
keypress: true, // default true
|
||||
locale: 'en', // [zh_CN,zh_TW,en,ja,pt,ru] waiting for PRs
|
||||
overflowHidden: false, // default false
|
||||
mainLinkStyle: 2, // [1,2] default 1
|
||||
mouseSelectionButton: 0, // 0 for left button, 2 for right button, default 0
|
||||
contextMenu: {
|
||||
focus: true,
|
||||
link: true,
|
||||
extend: [
|
||||
{
|
||||
name: 'Node edit',
|
||||
onclick: () => {
|
||||
alert('extend menu')
|
||||
},
|
||||
},
|
||||
],
|
||||
}, // default true
|
||||
before: {
|
||||
insertSibling(type, obj) {
|
||||
return true
|
||||
},
|
||||
},
|
||||
// Custom markdown parser (optional)
|
||||
// markdown: (text) => customMarkdownParser(text), // provide your own markdown parser function
|
||||
}
|
||||
|
||||
let mind = new MindElixir(options)
|
||||
|
||||
mind.install(plugin) // install your plugin
|
||||
|
||||
// create new map data
|
||||
const data = MindElixir.new('new topic')
|
||||
// or `example`
|
||||
// or the data return from `.getData()`
|
||||
mind.init(data)
|
||||
|
||||
// get a node
|
||||
MindElixir.E('node-id')
|
||||
```
|
||||
|
||||
### Data Structure
|
||||
|
||||
```javascript
|
||||
// whole node data structure up to now
|
||||
const nodeData = {
|
||||
topic: 'node topic',
|
||||
id: 'bd1c24420cd2c2f5',
|
||||
style: { fontSize: '32', color: '#3298db', background: '#ecf0f1' },
|
||||
expanded: true,
|
||||
parent: null,
|
||||
tags: ['Tag'],
|
||||
icons: ['😀'],
|
||||
hyperLink: 'https://github.com/ssshooter/mind-elixir-core',
|
||||
image: {
|
||||
url: 'https://raw.githubusercontent.com/ssshooter/mind-elixir-core/master/images/logo2.png', // required
|
||||
// you need to query the height and width of the image and calculate the appropriate value to display the image
|
||||
height: 90, // required
|
||||
width: 90, // required
|
||||
},
|
||||
children: [
|
||||
{
|
||||
topic: 'child',
|
||||
id: 'xxxx',
|
||||
// ...
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Event Handling
|
||||
|
||||
```javascript
|
||||
mind.bus.addListener('operation', operation => {
|
||||
console.log(operation)
|
||||
// return {
|
||||
// name: action name,
|
||||
// obj: target object
|
||||
// }
|
||||
|
||||
// name: [insertSibling|addChild|removeNode|beginEdit|finishEdit]
|
||||
// obj: target
|
||||
|
||||
// name: moveNode
|
||||
// obj: {from:target1,to:target2}
|
||||
})
|
||||
|
||||
mind.bus.addListener('selectNodes', nodes => {
|
||||
console.log(nodes)
|
||||
})
|
||||
|
||||
mind.bus.addListener('expandNode', node => {
|
||||
console.log('expandNode: ', node)
|
||||
})
|
||||
```
|
||||
|
||||
### Data Export And Import
|
||||
|
||||
```javascript
|
||||
// data export
|
||||
const data = mind.getData() // javascript object, see src/example.js
|
||||
mind.getDataString() // stringify object
|
||||
|
||||
// data import
|
||||
// initiate
|
||||
let mind = new MindElixir(options)
|
||||
mind.init(data)
|
||||
// data update
|
||||
mind.refresh(data)
|
||||
```
|
||||
|
||||
### Markdown Support
|
||||
|
||||
Mind Elixir supports custom markdown parsing:
|
||||
|
||||
```javascript
|
||||
// Disable markdown (default)
|
||||
let mind = new MindElixir({
|
||||
// markdown option omitted - no markdown processing
|
||||
})
|
||||
|
||||
// Use custom markdown parser
|
||||
let mind = new MindElixir({
|
||||
markdown: (text) => {
|
||||
// Your custom markdown implementation
|
||||
return text
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code>$1</code>')
|
||||
},
|
||||
})
|
||||
|
||||
// Use any markdown library (e.g., marked, markdown-it, etc.)
|
||||
import { marked } from 'marked'
|
||||
let mind = new MindElixir({
|
||||
markdown: (text) => marked(text),
|
||||
})
|
||||
```
|
||||
|
||||
For detailed markdown configuration examples, see [docs/markdown-configuration.md](docs/markdown-configuration.md).
|
||||
|
||||
### Operation Guards
|
||||
|
||||
```javascript
|
||||
let mind = new MindElixir({
|
||||
// ...
|
||||
before: {
|
||||
async addChild(el, obj) {
|
||||
await saveDataToDb()
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Export as a Image
|
||||
|
||||
Install `@ssshooter/modern-screenshot`, then:
|
||||
|
||||
```typescript
|
||||
import { domToPng } from '@ssshooter/modern-screenshot'
|
||||
|
||||
const download = async () => {
|
||||
const dataUrl = await domToPng(mind.nodes, {
|
||||
padding: 300,
|
||||
quality: 1,
|
||||
})
|
||||
const link = document.createElement('a')
|
||||
link.download = 'screenshot.png'
|
||||
link.href = dataUrl
|
||||
link.click()
|
||||
}
|
||||
```
|
||||
|
||||
## Theme
|
||||
|
||||
```javascript
|
||||
const options = {
|
||||
// ...
|
||||
theme: {
|
||||
name: 'Dark',
|
||||
// main lines color palette
|
||||
palette: ['#848FA0', '#748BE9', '#D2F9FE', '#4145A5', '#789AFA', '#706CF4', '#EF987F', '#775DD5', '#FCEECF', '#DA7FBC'],
|
||||
// overwrite css variables
|
||||
cssVar: {
|
||||
'--main-color': '#ffffff',
|
||||
'--main-bgcolor': '#4c4f69',
|
||||
'--color': '#cccccc',
|
||||
'--bgcolor': '#252526',
|
||||
'--panel-color': '255, 255, 255',
|
||||
'--panel-bgcolor': '45, 55, 72',
|
||||
},
|
||||
// all variables see /src/index.less
|
||||
},
|
||||
// ...
|
||||
}
|
||||
|
||||
// ...
|
||||
|
||||
mind.changeTheme({
|
||||
name: 'Latte',
|
||||
palette: ['#dd7878', '#ea76cb', '#8839ef', '#e64553', '#fe640b', '#df8e1d', '#40a02b', '#209fb5', '#1e66f5', '#7287fd'],
|
||||
cssVar: {
|
||||
'--main-color': '#444446',
|
||||
'--main-bgcolor': '#ffffff',
|
||||
'--color': '#777777',
|
||||
'--bgcolor': '#f6f6f6',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Be aware that Mind Elixir will not observe the change of `prefers-color-scheme`. Please change the theme **manually** when the scheme changes.
|
||||
|
||||
## Shortcuts
|
||||
|
||||
See [Shortcuts Guide](https://docs.mind-elixir.com/docs/guides/shortcuts) for detailed information.
|
||||
|
||||
## Who's using
|
||||
|
||||
- [Mind Elixir Desktop](https://desktop.mind-elixir.com/)
|
||||
|
||||
## Ecosystem
|
||||
|
||||
- [@mind-elixir/node-menu](https://github.com/ssshooter/node-menu)
|
||||
- [@mind-elixir/node-menu-neo](https://github.com/ssshooter/node-menu-neo)
|
||||
- [@mind-elixir/export-xmind](https://github.com/ssshooter/export-xmind)
|
||||
- [@mind-elixir/export-html](https://github.com/ssshooter/export-html)
|
||||
- [mind-elixir-react](https://github.com/ssshooter/mind-elixir-react)
|
||||
|
||||
PRs are welcome!
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
pnpm i
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Test generated files with `dev.dist.ts`:
|
||||
|
||||
```
|
||||
pnpm build
|
||||
pnpm link ./
|
||||
```
|
||||
|
||||
Update docs:
|
||||
|
||||
```
|
||||
# Install api-extractor
|
||||
pnpm install -g @microsoft/api-extractor
|
||||
# Maintain /src/docs.ts
|
||||
# Generate docs
|
||||
pnpm doc
|
||||
pnpm doc:md
|
||||
```
|
||||
|
||||
Use [DeepWiki](https://deepwiki.com/SSShooter/mind-elixir-core) to learn more about Mind Elixir
|
||||
|
||||
## Thanks
|
||||
|
||||
- [@viselect/vanilla](https://github.com/simonwep/selection/tree/master/packages/vanilla)
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks for your contributions to Mind Elixir! Your support and dedication make this project better.
|
||||
|
||||
<a href="https://github.com/SSShooter/mind-elixir-core/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=SSShooter/mind-elixir-core" />
|
||||
</a>
|
||||
|
|
@ -1,556 +0,0 @@
|
|||
import { generateUUID, getArrowPoints, getObjById, getOffsetLT, setAttributes } from './utils/index'
|
||||
import LinkDragMoveHelper from './utils/LinkDragMoveHelper'
|
||||
import { createSvgGroup, createSvgText, editSvgText, svgNS } from './utils/svg'
|
||||
import type { CustomSvg, Topic } from './types/dom'
|
||||
import type { MindElixirInstance, Uid } from './index'
|
||||
|
||||
const highlightColor = '#4dc4ff'
|
||||
|
||||
export interface Arrow {
|
||||
id: string
|
||||
/**
|
||||
* label of arrow
|
||||
*/
|
||||
label: string
|
||||
/**
|
||||
* id of start node
|
||||
*/
|
||||
from: Uid
|
||||
/**
|
||||
* id of end node
|
||||
*/
|
||||
to: Uid
|
||||
/**
|
||||
* offset of control point from start point
|
||||
*/
|
||||
delta1: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
/**
|
||||
* offset of control point from end point
|
||||
*/
|
||||
delta2: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
/**
|
||||
* whether the arrow is bidirectional
|
||||
*/
|
||||
bidirectional?: boolean
|
||||
/**
|
||||
* style properties for the arrow
|
||||
*/
|
||||
style?: {
|
||||
/**
|
||||
* stroke color of the arrow
|
||||
*/
|
||||
stroke?: string
|
||||
/**
|
||||
* stroke width of the arrow
|
||||
*/
|
||||
strokeWidth?: string | number
|
||||
/**
|
||||
* stroke dash array for dashed lines
|
||||
*/
|
||||
strokeDasharray?: string
|
||||
/**
|
||||
* stroke line cap style
|
||||
*/
|
||||
strokeLinecap?: 'butt' | 'round' | 'square'
|
||||
/**
|
||||
* opacity of the arrow
|
||||
*/
|
||||
opacity?: string | number
|
||||
/**
|
||||
* color of the arrow label
|
||||
*/
|
||||
labelColor?: string
|
||||
}
|
||||
}
|
||||
export type DivData = {
|
||||
cx: number // center x
|
||||
cy: number // center y
|
||||
w: number // div width
|
||||
h: number // div height
|
||||
ctrlX: number // control point x
|
||||
ctrlY: number // control point y
|
||||
}
|
||||
export type ArrowOptions = {
|
||||
bidirectional?: boolean
|
||||
style?: {
|
||||
stroke?: string
|
||||
strokeWidth?: string | number
|
||||
strokeDasharray?: string
|
||||
strokeLinecap?: 'butt' | 'round' | 'square'
|
||||
opacity?: string | number
|
||||
labelColor?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bezier curve midpoint position
|
||||
*/
|
||||
function calcBezierMidPoint(p1x: number, p1y: number, p2x: number, p2y: number, p3x: number, p3y: number, p4x: number, p4y: number) {
|
||||
return {
|
||||
x: p1x / 8 + (p2x * 3) / 8 + (p3x * 3) / 8 + p4x / 8,
|
||||
y: p1y / 8 + (p2y * 3) / 8 + (p3y * 3) / 8 + p4y / 8,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update arrow label position
|
||||
*/
|
||||
function updateArrowLabel(label: SVGTextElement, x: number, y: number) {
|
||||
setAttributes(label, {
|
||||
x: x + '',
|
||||
y: y + '',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update control line position
|
||||
*/
|
||||
function updateControlLine(line: SVGElement, x1: number, y1: number, x2: number, y2: number) {
|
||||
setAttributes(line, {
|
||||
x1: x1 + '',
|
||||
y1: y1 + '',
|
||||
x2: x2 + '',
|
||||
y2: y2 + '',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update arrow path and related elements
|
||||
*/
|
||||
function updateArrowPath(
|
||||
arrow: CustomSvg,
|
||||
p1x: number,
|
||||
p1y: number,
|
||||
p2x: number,
|
||||
p2y: number,
|
||||
p3x: number,
|
||||
p3y: number,
|
||||
p4x: number,
|
||||
p4y: number,
|
||||
linkItem: Arrow
|
||||
) {
|
||||
const mainPath = `M ${p1x} ${p1y} C ${p2x} ${p2y} ${p3x} ${p3y} ${p4x} ${p4y}`
|
||||
|
||||
// Update main path
|
||||
arrow.line.setAttribute('d', mainPath)
|
||||
|
||||
// Apply styles to the main line if they exist
|
||||
if (linkItem.style) {
|
||||
const style = linkItem.style
|
||||
if (style.stroke) arrow.line.setAttribute('stroke', style.stroke)
|
||||
if (style.strokeWidth) arrow.line.setAttribute('stroke-width', String(style.strokeWidth))
|
||||
if (style.strokeDasharray) arrow.line.setAttribute('stroke-dasharray', style.strokeDasharray)
|
||||
if (style.strokeLinecap) arrow.line.setAttribute('stroke-linecap', style.strokeLinecap)
|
||||
if (style.opacity !== undefined) arrow.line.setAttribute('opacity', String(style.opacity))
|
||||
}
|
||||
|
||||
// Update hotzone for main path (find the first hotzone path which corresponds to the main line)
|
||||
const hotzones = arrow.querySelectorAll('path[stroke="transparent"]')
|
||||
if (hotzones.length > 0) {
|
||||
hotzones[0].setAttribute('d', mainPath)
|
||||
}
|
||||
|
||||
// Update arrow head
|
||||
const arrowPoint = getArrowPoints(p3x, p3y, p4x, p4y)
|
||||
if (arrowPoint) {
|
||||
const arrowPath1 = `M ${arrowPoint.x1} ${arrowPoint.y1} L ${p4x} ${p4y} L ${arrowPoint.x2} ${arrowPoint.y2}`
|
||||
arrow.arrow1.setAttribute('d', arrowPath1)
|
||||
|
||||
// Update hotzone for arrow1
|
||||
if (hotzones.length > 1) {
|
||||
hotzones[1].setAttribute('d', arrowPath1)
|
||||
}
|
||||
|
||||
// Apply styles to arrow head
|
||||
if (linkItem.style) {
|
||||
const style = linkItem.style
|
||||
if (style.stroke) arrow.arrow1.setAttribute('stroke', style.stroke)
|
||||
if (style.strokeWidth) arrow.arrow1.setAttribute('stroke-width', String(style.strokeWidth))
|
||||
if (style.strokeLinecap) arrow.arrow1.setAttribute('stroke-linecap', style.strokeLinecap)
|
||||
if (style.opacity !== undefined) arrow.arrow1.setAttribute('opacity', String(style.opacity))
|
||||
}
|
||||
}
|
||||
|
||||
// Update start arrow if bidirectional
|
||||
if (linkItem.bidirectional) {
|
||||
const arrowPointStart = getArrowPoints(p2x, p2y, p1x, p1y)
|
||||
if (arrowPointStart) {
|
||||
const arrowPath2 = `M ${arrowPointStart.x1} ${arrowPointStart.y1} L ${p1x} ${p1y} L ${arrowPointStart.x2} ${arrowPointStart.y2}`
|
||||
arrow.arrow2.setAttribute('d', arrowPath2)
|
||||
|
||||
// Update hotzone for arrow2
|
||||
if (hotzones.length > 2) {
|
||||
hotzones[2].setAttribute('d', arrowPath2)
|
||||
}
|
||||
|
||||
// Apply styles to start arrow head
|
||||
if (linkItem.style) {
|
||||
const style = linkItem.style
|
||||
if (style.stroke) arrow.arrow2.setAttribute('stroke', style.stroke)
|
||||
if (style.strokeWidth) arrow.arrow2.setAttribute('stroke-width', String(style.strokeWidth))
|
||||
if (style.strokeLinecap) arrow.arrow2.setAttribute('stroke-linecap', style.strokeLinecap)
|
||||
if (style.opacity !== undefined) arrow.arrow2.setAttribute('opacity', String(style.opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update label position and color
|
||||
const { x: halfx, y: halfy } = calcBezierMidPoint(p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y)
|
||||
updateArrowLabel(arrow.label, halfx, halfy)
|
||||
|
||||
// Apply label color if specified
|
||||
if (linkItem.style?.labelColor) {
|
||||
arrow.label.setAttribute('fill', linkItem.style.labelColor)
|
||||
}
|
||||
|
||||
// Update highlight layer
|
||||
updateArrowHighlight(arrow)
|
||||
}
|
||||
|
||||
/**
|
||||
* calc control point, center point and div size
|
||||
*/
|
||||
function calcCtrlP(mei: MindElixirInstance, tpc: Topic, delta: { x: number; y: number }) {
|
||||
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc)
|
||||
const w = tpc.offsetWidth
|
||||
const h = tpc.offsetHeight
|
||||
const cx = x + w / 2
|
||||
const cy = y + h / 2
|
||||
const ctrlX = cx + delta.x
|
||||
const ctrlY = cy + delta.y
|
||||
return {
|
||||
w,
|
||||
h,
|
||||
cx,
|
||||
cy,
|
||||
ctrlX,
|
||||
ctrlY,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* calc start and end point using control point and div status
|
||||
*/
|
||||
function calcP(data: DivData) {
|
||||
let x, y
|
||||
const k = (data.cy - data.ctrlY) / (data.ctrlX - data.cx)
|
||||
if (k > data.h / data.w || k < -data.h / data.w) {
|
||||
if (data.cy - data.ctrlY < 0) {
|
||||
x = data.cx - data.h / 2 / k
|
||||
y = data.cy + data.h / 2
|
||||
} else {
|
||||
x = data.cx + data.h / 2 / k
|
||||
y = data.cy - data.h / 2
|
||||
}
|
||||
} else {
|
||||
if (data.cx - data.ctrlX < 0) {
|
||||
x = data.cx + data.w / 2
|
||||
y = data.cy - (data.w * k) / 2
|
||||
} else {
|
||||
x = data.cx - data.w / 2
|
||||
y = data.cy + (data.w * k) / 2
|
||||
}
|
||||
}
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FYI
|
||||
* p1: start point
|
||||
* p2: control point of start point
|
||||
* p3: control point of end point
|
||||
* p4: end point
|
||||
*/
|
||||
const drawArrow = function (mei: MindElixirInstance, from: Topic, to: Topic, obj: Arrow, isInitPaint?: boolean) {
|
||||
if (!from || !to) {
|
||||
return // not expand
|
||||
}
|
||||
|
||||
const fromData = calcCtrlP(mei, from, obj.delta1)
|
||||
const toData = calcCtrlP(mei, to, obj.delta2)
|
||||
|
||||
const { x: p1x, y: p1y } = calcP(fromData)
|
||||
const { ctrlX: p2x, ctrlY: p2y } = fromData
|
||||
const { ctrlX: p3x, ctrlY: p3y } = toData
|
||||
const { x: p4x, y: p4y } = calcP(toData)
|
||||
|
||||
const arrowT = getArrowPoints(p3x, p3y, p4x, p4y)
|
||||
if (!arrowT) return
|
||||
|
||||
const toArrow = `M ${arrowT.x1} ${arrowT.y1} L ${p4x} ${p4y} L ${arrowT.x2} ${arrowT.y2}`
|
||||
let fromArrow = ''
|
||||
if (obj.bidirectional) {
|
||||
const arrowF = getArrowPoints(p2x, p2y, p1x, p1y)
|
||||
if (!arrowF) return
|
||||
fromArrow = `M ${arrowF.x1} ${arrowF.y1} L ${p1x} ${p1y} L ${arrowF.x2} ${arrowF.y2}`
|
||||
}
|
||||
const newSvgGroup = createSvgGroup(`M ${p1x} ${p1y} C ${p2x} ${p2y} ${p3x} ${p3y} ${p4x} ${p4y}`, toArrow, fromArrow, obj.style)
|
||||
|
||||
// Use extracted common function to calculate midpoint
|
||||
const { x: halfx, y: halfy } = calcBezierMidPoint(p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y)
|
||||
const labelColor = obj.style?.labelColor
|
||||
const label = createSvgText(obj.label, halfx, halfy, {
|
||||
anchor: 'middle',
|
||||
color: labelColor,
|
||||
dataType: 'custom-link',
|
||||
})
|
||||
newSvgGroup.appendChild(label)
|
||||
newSvgGroup.label = label
|
||||
|
||||
newSvgGroup.arrowObj = obj
|
||||
newSvgGroup.dataset.linkid = obj.id
|
||||
mei.linkSvgGroup.appendChild(newSvgGroup)
|
||||
if (!isInitPaint) {
|
||||
mei.arrows.push(obj)
|
||||
mei.currentArrow = newSvgGroup
|
||||
showLinkController(mei, obj, fromData, toData)
|
||||
}
|
||||
}
|
||||
|
||||
export const createArrow = function (this: MindElixirInstance, from: Topic, to: Topic, options: ArrowOptions = {}) {
|
||||
const arrowObj = {
|
||||
id: generateUUID(),
|
||||
label: 'Custom Link',
|
||||
from: from.nodeObj.id,
|
||||
to: to.nodeObj.id,
|
||||
delta1: {
|
||||
x: from.offsetWidth / 2 + 100,
|
||||
y: 0,
|
||||
},
|
||||
delta2: {
|
||||
x: to.offsetWidth / 2 + 100,
|
||||
y: 0,
|
||||
},
|
||||
...options,
|
||||
}
|
||||
drawArrow(this, from, to, arrowObj)
|
||||
|
||||
this.bus.fire('operation', {
|
||||
name: 'createArrow',
|
||||
obj: arrowObj,
|
||||
})
|
||||
}
|
||||
|
||||
export const createArrowFrom = function (this: MindElixirInstance, arrow: Omit<Arrow, 'id'>) {
|
||||
hideLinkController(this)
|
||||
const arrowObj = { ...arrow, id: generateUUID() }
|
||||
drawArrow(this, this.findEle(arrowObj.from), this.findEle(arrowObj.to), arrowObj)
|
||||
|
||||
this.bus.fire('operation', {
|
||||
name: 'createArrow',
|
||||
obj: arrowObj,
|
||||
})
|
||||
}
|
||||
|
||||
export const removeArrow = function (this: MindElixirInstance, linkSvg?: CustomSvg) {
|
||||
let link
|
||||
if (linkSvg) {
|
||||
link = linkSvg
|
||||
} else {
|
||||
link = this.currentArrow
|
||||
}
|
||||
if (!link) return
|
||||
hideLinkController(this)
|
||||
const id = link.arrowObj!.id
|
||||
this.arrows = this.arrows.filter(arrow => arrow.id !== id)
|
||||
link.remove()
|
||||
this.bus.fire('operation', {
|
||||
name: 'removeArrow',
|
||||
obj: {
|
||||
id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const selectArrow = function (this: MindElixirInstance, link: CustomSvg) {
|
||||
this.currentArrow = link
|
||||
const obj = link.arrowObj
|
||||
|
||||
const from = this.findEle(obj.from)
|
||||
const to = this.findEle(obj.to)
|
||||
|
||||
const fromData = calcCtrlP(this, from, obj.delta1)
|
||||
const toData = calcCtrlP(this, to, obj.delta2)
|
||||
|
||||
showLinkController(this, obj, fromData, toData)
|
||||
}
|
||||
|
||||
export const unselectArrow = function (this: MindElixirInstance) {
|
||||
hideLinkController(this)
|
||||
this.currentArrow = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a highlight path element with common attributes
|
||||
*/
|
||||
const createHighlightPath = function (d: string, highlightColor: string): SVGPathElement {
|
||||
const path = document.createElementNS(svgNS, 'path')
|
||||
setAttributes(path, {
|
||||
d,
|
||||
stroke: highlightColor,
|
||||
fill: 'none',
|
||||
'stroke-width': '6',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
})
|
||||
return path
|
||||
}
|
||||
|
||||
const addArrowHighlight = function (arrow: CustomSvg, highlightColor: string) {
|
||||
const highlightGroup = document.createElementNS(svgNS, 'g')
|
||||
highlightGroup.setAttribute('class', 'arrow-highlight')
|
||||
highlightGroup.setAttribute('opacity', '0.45')
|
||||
|
||||
const highlightLine = createHighlightPath(arrow.line.getAttribute('d')!, highlightColor)
|
||||
highlightGroup.appendChild(highlightLine)
|
||||
|
||||
const highlightArrow1 = createHighlightPath(arrow.arrow1.getAttribute('d')!, highlightColor)
|
||||
highlightGroup.appendChild(highlightArrow1)
|
||||
|
||||
if (arrow.arrow2.getAttribute('d')) {
|
||||
const highlightArrow2 = createHighlightPath(arrow.arrow2.getAttribute('d')!, highlightColor)
|
||||
highlightGroup.appendChild(highlightArrow2)
|
||||
}
|
||||
|
||||
arrow.insertBefore(highlightGroup, arrow.firstChild)
|
||||
}
|
||||
|
||||
const removeArrowHighlight = function (arrow: CustomSvg) {
|
||||
const highlightGroup = arrow.querySelector('.arrow-highlight')
|
||||
if (highlightGroup) {
|
||||
highlightGroup.remove()
|
||||
}
|
||||
}
|
||||
|
||||
const updateArrowHighlight = function (arrow: CustomSvg) {
|
||||
const highlightGroup = arrow.querySelector('.arrow-highlight')
|
||||
if (!highlightGroup) return
|
||||
|
||||
const highlightPaths = highlightGroup.querySelectorAll('path')
|
||||
if (highlightPaths.length >= 1) {
|
||||
highlightPaths[0].setAttribute('d', arrow.line.getAttribute('d')!)
|
||||
}
|
||||
if (highlightPaths.length >= 2) {
|
||||
highlightPaths[1].setAttribute('d', arrow.arrow1.getAttribute('d')!)
|
||||
}
|
||||
if (highlightPaths.length >= 3 && arrow.arrow2.getAttribute('d')) {
|
||||
highlightPaths[2].setAttribute('d', arrow.arrow2.getAttribute('d')!)
|
||||
}
|
||||
}
|
||||
|
||||
const hideLinkController = function (mei: MindElixirInstance) {
|
||||
mei.helper1?.destroy!()
|
||||
mei.helper2?.destroy!()
|
||||
mei.linkController.style.display = 'none'
|
||||
mei.P2.style.display = 'none'
|
||||
mei.P3.style.display = 'none'
|
||||
if (mei.currentArrow) {
|
||||
removeArrowHighlight(mei.currentArrow)
|
||||
}
|
||||
}
|
||||
|
||||
const showLinkController = function (mei: MindElixirInstance, linkItem: Arrow, fromData: DivData, toData: DivData) {
|
||||
const { linkController, P2, P3, line1, line2, nodes, map, currentArrow, bus } = mei
|
||||
if (!currentArrow) return
|
||||
linkController.style.display = 'initial'
|
||||
P2.style.display = 'initial'
|
||||
P3.style.display = 'initial'
|
||||
nodes.appendChild(linkController)
|
||||
nodes.appendChild(P2)
|
||||
nodes.appendChild(P3)
|
||||
|
||||
addArrowHighlight(currentArrow, highlightColor)
|
||||
|
||||
// init points
|
||||
let { x: p1x, y: p1y } = calcP(fromData)
|
||||
let { ctrlX: p2x, ctrlY: p2y } = fromData
|
||||
let { ctrlX: p3x, ctrlY: p3y } = toData
|
||||
let { x: p4x, y: p4y } = calcP(toData)
|
||||
|
||||
P2.style.cssText = `top:${p2y}px;left:${p2x}px;`
|
||||
P3.style.cssText = `top:${p3y}px;left:${p3x}px;`
|
||||
updateControlLine(line1, p1x, p1y, p2x, p2y)
|
||||
updateControlLine(line2, p3x, p3y, p4x, p4y)
|
||||
|
||||
mei.helper1 = LinkDragMoveHelper.create(P2)
|
||||
mei.helper2 = LinkDragMoveHelper.create(P3)
|
||||
|
||||
mei.helper1.init(map, (deltaX, deltaY) => {
|
||||
// recalc key points
|
||||
p2x = p2x + deltaX / mei.scaleVal // scale should keep the latest value
|
||||
p2y = p2y + deltaY / mei.scaleVal
|
||||
const p1 = calcP({ ...fromData, ctrlX: p2x, ctrlY: p2y })
|
||||
p1x = p1.x
|
||||
p1y = p1.y
|
||||
|
||||
// update dom position
|
||||
P2.style.top = p2y + 'px'
|
||||
P2.style.left = p2x + 'px'
|
||||
|
||||
// Use extracted common function to update arrow
|
||||
updateArrowPath(currentArrow, p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y, linkItem)
|
||||
updateControlLine(line1, p1x, p1y, p2x, p2y)
|
||||
|
||||
linkItem.delta1.x = p2x - fromData.cx
|
||||
linkItem.delta1.y = p2y - fromData.cy
|
||||
|
||||
bus.fire('updateArrowDelta', linkItem)
|
||||
})
|
||||
|
||||
mei.helper2.init(map, (deltaX, deltaY) => {
|
||||
p3x = p3x + deltaX / mei.scaleVal
|
||||
p3y = p3y + deltaY / mei.scaleVal
|
||||
const p4 = calcP({ ...toData, ctrlX: p3x, ctrlY: p3y })
|
||||
p4x = p4.x
|
||||
p4y = p4.y
|
||||
|
||||
P3.style.top = p3y + 'px'
|
||||
P3.style.left = p3x + 'px'
|
||||
|
||||
// Use extracted common function to update arrow
|
||||
updateArrowPath(currentArrow, p1x, p1y, p2x, p2y, p3x, p3y, p4x, p4y, linkItem)
|
||||
updateControlLine(line2, p3x, p3y, p4x, p4y)
|
||||
|
||||
linkItem.delta2.x = p3x - toData.cx
|
||||
linkItem.delta2.y = p3y - toData.cy
|
||||
|
||||
bus.fire('updateArrowDelta', linkItem)
|
||||
})
|
||||
}
|
||||
|
||||
export function renderArrow(this: MindElixirInstance) {
|
||||
this.linkSvgGroup.innerHTML = ''
|
||||
for (let i = 0; i < this.arrows.length; i++) {
|
||||
const link = this.arrows[i]
|
||||
try {
|
||||
drawArrow(this, this.findEle(link.from), this.findEle(link.to), link, true)
|
||||
} catch (e) {
|
||||
console.warn('Node may not be expanded')
|
||||
}
|
||||
}
|
||||
this.nodes.appendChild(this.linkSvgGroup)
|
||||
}
|
||||
|
||||
export function editArrowLabel(this: MindElixirInstance, el: CustomSvg) {
|
||||
hideLinkController(this)
|
||||
console.time('editSummary')
|
||||
if (!el) return
|
||||
const textEl = el.label
|
||||
editSvgText(this, textEl, el.arrowObj)
|
||||
console.timeEnd('editSummary')
|
||||
}
|
||||
|
||||
export function tidyArrow(this: MindElixirInstance) {
|
||||
this.arrows = this.arrows.filter(arrow => {
|
||||
return getObjById(arrow.from, this.nodeData) && getObjById(arrow.to, this.nodeData)
|
||||
})
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import type { Theme } from '.'
|
||||
|
||||
export const LEFT = 0
|
||||
export const RIGHT = 1
|
||||
export const SIDE = 2
|
||||
export const DOWN = 3
|
||||
|
||||
export const THEME: Theme = {
|
||||
name: 'Latte',
|
||||
type: 'light',
|
||||
palette: ['#dd7878', '#ea76cb', '#8839ef', '#e64553', '#fe640b', '#df8e1d', '#40a02b', '#209fb5', '#1e66f5', '#7287fd'],
|
||||
cssVar: {
|
||||
'--node-gap-x': '30px',
|
||||
'--node-gap-y': '10px',
|
||||
'--main-gap-x': '65px',
|
||||
'--main-gap-y': '45px',
|
||||
'--root-radius': '30px',
|
||||
'--main-radius': '20px',
|
||||
'--root-color': '#ffffff',
|
||||
'--root-bgcolor': '#4c4f69',
|
||||
'--root-border-color': 'rgba(0, 0, 0, 0)',
|
||||
'--main-color': '#444446',
|
||||
'--main-bgcolor': '#ffffff',
|
||||
'--topic-padding': '3px',
|
||||
'--color': '#777777',
|
||||
'--bgcolor': '#f6f6f6',
|
||||
'--selected': '#4dc4ff',
|
||||
'--accent-color': '#e64553',
|
||||
'--panel-color': '#444446',
|
||||
'--panel-bgcolor': '#ffffff',
|
||||
'--panel-border-color': '#eaeaea',
|
||||
'--map-padding': '50px',
|
||||
},
|
||||
}
|
||||
|
||||
export const DARK_THEME: Theme = {
|
||||
name: 'Dark',
|
||||
type: 'dark',
|
||||
palette: ['#848FA0', '#748BE9', '#D2F9FE', '#4145A5', '#789AFA', '#706CF4', '#EF987F', '#775DD5', '#FCEECF', '#DA7FBC'],
|
||||
cssVar: {
|
||||
'--node-gap-x': '30px',
|
||||
'--node-gap-y': '10px',
|
||||
'--main-gap-x': '65px',
|
||||
'--main-gap-y': '45px',
|
||||
'--root-radius': '30px',
|
||||
'--main-radius': '20px',
|
||||
'--root-color': '#ffffff',
|
||||
'--root-bgcolor': '#2d3748',
|
||||
'--root-border-color': 'rgba(255, 255, 255, 0.1)',
|
||||
'--main-color': '#ffffff',
|
||||
'--main-bgcolor': '#4c4f69',
|
||||
'--topic-padding': '3px',
|
||||
'--color': '#cccccc',
|
||||
'--bgcolor': '#252526',
|
||||
'--selected': '#4dc4ff',
|
||||
'--accent-color': '#789AFA',
|
||||
'--panel-color': '#ffffff',
|
||||
'--panel-bgcolor': '#2d3748',
|
||||
'--panel-border-color': '#696969',
|
||||
'--map-padding': '50px 80px',
|
||||
},
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import MindElixir from 'mind-elixir'
|
||||
import example from 'mind-elixir/example'
|
||||
import type { Options } from 'mind-elixir'
|
||||
|
||||
const E = MindElixir.E
|
||||
const options: Options = {
|
||||
el: '#map',
|
||||
newTopicName: '子节点',
|
||||
// direction: MindElixir.LEFT,
|
||||
direction: MindElixir.RIGHT,
|
||||
// data: MindElixir.new('new topic'),
|
||||
locale: 'en',
|
||||
draggable: true,
|
||||
editable: true,
|
||||
contextMenu: {
|
||||
focus: true,
|
||||
link: true,
|
||||
extend: [
|
||||
{
|
||||
name: 'Node edit',
|
||||
onclick: () => {
|
||||
alert('extend menu')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
toolBar: true,
|
||||
keypress: true,
|
||||
allowUndo: false,
|
||||
before: {
|
||||
moveDownNode() {
|
||||
return false
|
||||
},
|
||||
insertSibling(el, obj) {
|
||||
console.log('insertSibling', el, obj)
|
||||
return true
|
||||
},
|
||||
async addChild(el, obj) {
|
||||
return true
|
||||
},
|
||||
},
|
||||
scaleSensitivity: 0.2,
|
||||
}
|
||||
const mind = new MindElixir(options)
|
||||
mind.init(example)
|
||||
function sleep() {
|
||||
return new Promise<void>(res => {
|
||||
setTimeout(() => res(), 1000)
|
||||
})
|
||||
}
|
||||
console.log('test E function', E('bd4313fbac40284b'))
|
||||
// let mind2 = new MindElixir({
|
||||
// el: '#map2',
|
||||
// direction: 2,
|
||||
// data: MindElixir.example2,
|
||||
// draggable: false,
|
||||
// // overflowHidden: true,
|
||||
// nodeMenu: true,
|
||||
// })
|
||||
// mind2.init()
|
||||
|
||||
mind.bus.addListener('operation', operation => {
|
||||
console.log(operation)
|
||||
})
|
||||
|
||||
mind.bus.addListener('selectNodes', nodes => {
|
||||
console.log(nodes)
|
||||
})
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
import type { MindElixirCtor } from './index'
|
||||
import MindElixir from './index'
|
||||
import example from './exampleData/1'
|
||||
import example2 from './exampleData/2'
|
||||
import example3 from './exampleData/3'
|
||||
import type { Options, MindElixirInstance, NodeObj } from './types/index'
|
||||
import type { Operation } from './utils/pubsub'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import katex from 'katex'
|
||||
import { layoutSSR, renderSSRHTML } from './utils/layout-ssr'
|
||||
import { snapdom } from '@zumer/snapdom'
|
||||
import type { Tokens } from 'marked'
|
||||
import { marked } from 'marked'
|
||||
import { md2html } from 'simple-markdown-to-html'
|
||||
|
||||
interface Window {
|
||||
m?: MindElixirInstance
|
||||
M: MindElixirCtor
|
||||
E: typeof MindElixir.E
|
||||
downloadPng: () => void
|
||||
downloadSvg: () => void
|
||||
destroy: () => void
|
||||
testMarkdown: () => void
|
||||
addMarkdownNode: () => void
|
||||
}
|
||||
|
||||
declare let window: Window
|
||||
|
||||
const E = MindElixir.E
|
||||
const options: Options = {
|
||||
el: '#map',
|
||||
newTopicName: '子节点',
|
||||
locale: 'en',
|
||||
// mouseSelectionButton: 2,
|
||||
draggable: true,
|
||||
editable: true,
|
||||
markdown: (text: string, obj: NodeObj & { useMd?: boolean }) => {
|
||||
if (!text) return ''
|
||||
if (!obj.useMd) return text
|
||||
try {
|
||||
// Configure marked renderer to add target="_blank" to links and table classes
|
||||
const renderer = {
|
||||
link(token: Tokens.Link) {
|
||||
const href = token.href || ''
|
||||
const title = token.title ? ` title="${token.title}"` : ''
|
||||
const text = token.text || ''
|
||||
return `<a href="${href}"${title} target="_blank">${text}</a>`
|
||||
},
|
||||
table(token: Tokens.Table) {
|
||||
const header = token.header.map(cell => `<th>${cell.text}</th>`).join('')
|
||||
const rows = token.rows.map(row =>
|
||||
`<tr>${row.map(cell => `<td>${cell.text}</td>`).join('')}</tr>`
|
||||
).join('')
|
||||
return `<table class="markdown-table"><thead><tr>${header}</tr></thead><tbody>${rows}</tbody></table>`
|
||||
},
|
||||
}
|
||||
|
||||
marked.use({ renderer, gfm: true })
|
||||
let html = marked(text) as string
|
||||
// let html = md2html(text)
|
||||
|
||||
// Process KaTeX math expressions
|
||||
// Handle display math ($$...$$)
|
||||
html = html.replace(/\$\$([^$]+)\$\$/g, (_, math) => {
|
||||
return katex.renderToString(math.trim(), { displayMode: true })
|
||||
})
|
||||
|
||||
// Handle inline math ($...$)
|
||||
html = html.replace(/\$([^$]+)\$/g, (_, math) => {
|
||||
return katex.renderToString(math.trim(), { displayMode: false })
|
||||
})
|
||||
|
||||
return html.trim().replace(/\n/g, '')
|
||||
} catch (error) {
|
||||
return text
|
||||
}
|
||||
},
|
||||
// To disable markdown, simply omit the markdown option or set it to undefined
|
||||
// if you set contextMenu to false, you should handle contextmenu event by yourself, e.g. preventDefault
|
||||
contextMenu: {
|
||||
focus: true,
|
||||
link: true,
|
||||
extend: [
|
||||
{
|
||||
name: 'Node edit',
|
||||
onclick: () => {
|
||||
alert('extend menu')
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
toolBar: true,
|
||||
keypress: {
|
||||
e(e) {
|
||||
if (!mind.currentNode) return
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
mind.expandNode(mind.currentNode)
|
||||
}
|
||||
},
|
||||
f(e) {
|
||||
if (!mind.currentNode) return
|
||||
if (e.altKey) {
|
||||
if (mind.isFocusMode) {
|
||||
mind.cancelFocus()
|
||||
} else {
|
||||
mind.focusNode(mind.currentNode)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
allowUndo: true,
|
||||
before: {
|
||||
insertSibling(el, obj) {
|
||||
console.log('insertSibling', el, obj)
|
||||
return true
|
||||
},
|
||||
async addChild(el, obj) {
|
||||
console.log('addChild', el, obj)
|
||||
// await sleep()
|
||||
return true
|
||||
},
|
||||
},
|
||||
// scaleMin:0.1
|
||||
// alignment: 'nodes',
|
||||
}
|
||||
|
||||
let mind = new MindElixir(options)
|
||||
|
||||
const data = MindElixir.new('new topic')
|
||||
mind.init(example)
|
||||
|
||||
const m2 = new MindElixir({
|
||||
el: '#map2',
|
||||
selectionContainer: 'body', // use body to make selection usable when transform is not 0
|
||||
direction: MindElixir.RIGHT,
|
||||
theme: MindElixir.DARK_THEME,
|
||||
// alignment: 'nodes',
|
||||
})
|
||||
m2.init(data)
|
||||
|
||||
function sleep() {
|
||||
return new Promise<void>(res => {
|
||||
setTimeout(() => res(), 1000)
|
||||
})
|
||||
}
|
||||
// console.log('test E function', E('bd4313fbac40284b'))
|
||||
|
||||
mind.bus.addListener('operation', (operation: Operation) => {
|
||||
console.log(operation)
|
||||
// return {
|
||||
// name: action name,
|
||||
// obj: target object
|
||||
// }
|
||||
|
||||
// name: [insertSibling|addChild|removeNode|beginEdit|finishEdit]
|
||||
// obj: target
|
||||
|
||||
// name: moveNodeIn
|
||||
// obj: {from:target1,to:target2}
|
||||
})
|
||||
mind.bus.addListener('selectNodes', nodes => {
|
||||
console.log('selectNodes', nodes)
|
||||
})
|
||||
mind.bus.addListener('unselectNodes', nodes => {
|
||||
console.log('unselectNodes', nodes)
|
||||
})
|
||||
mind.bus.addListener('expandNode', node => {
|
||||
console.log('expandNode: ', node)
|
||||
})
|
||||
|
||||
const dl2 = async () => {
|
||||
const result = await snapdom(mind.nodes)
|
||||
await result.download({ format: 'jpg', filename: 'my-capture' })
|
||||
}
|
||||
|
||||
window.downloadPng = dl2
|
||||
window.m = mind
|
||||
// window.m2 = mind2
|
||||
window.M = MindElixir
|
||||
window.E = MindElixir.E
|
||||
|
||||
console.log('MindElixir Version', MindElixir.version)
|
||||
|
||||
window.destroy = () => {
|
||||
mind.destroy()
|
||||
// @ts-expect-error remove reference
|
||||
mind = null
|
||||
// @ts-expect-error remove reference
|
||||
window.m = null
|
||||
}
|
||||
|
||||
document.querySelector('#ssr')!.innerHTML = renderSSRHTML(layoutSSR(window.m.nodeData))
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import type { Arrow } from './arrow'
|
||||
import type methods from './methods'
|
||||
import type { MindElixirMethods } from './methods'
|
||||
import type { Summary, SummarySvgGroup } from './summary'
|
||||
import type { MindElixirData, MindElixirInstance, NodeObj, NodeObjExport, Options, Theme } from './types'
|
||||
import type { MainLineParams, SubLineParams } from './utils/generateBranch'
|
||||
import type { Locale } from './i18n'
|
||||
export {
|
||||
methods,
|
||||
Theme,
|
||||
Options,
|
||||
MindElixirMethods,
|
||||
MindElixirInstance,
|
||||
MindElixirData,
|
||||
NodeObj,
|
||||
NodeObjExport,
|
||||
Summary,
|
||||
SummarySvgGroup,
|
||||
Arrow,
|
||||
MainLineParams,
|
||||
SubLineParams,
|
||||
Locale,
|
||||
}
|
||||
|
||||
export type * from './types/dom'
|
||||
export type * from './utils/pubsub'
|
||||
|
|
@ -1,544 +0,0 @@
|
|||
import type { MindElixirData } from '../index'
|
||||
import { codeBlock, katexHTML, styledDiv } from './htmlText'
|
||||
|
||||
const aboutMindElixir: MindElixirData = {
|
||||
nodeData: {
|
||||
id: 'me-root',
|
||||
topic: 'Mind Elixir',
|
||||
tags: ['思维导图内核'],
|
||||
children: [
|
||||
{
|
||||
topic: 'logo2',
|
||||
id: '56dae51a90d350a8',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
id: 'use-image',
|
||||
topic: 'mind-elixir',
|
||||
image: {
|
||||
url: 'https://raw.githubusercontent.com/ssshooter/mind-elixir-core/master/images/logo2.png',
|
||||
height: 100,
|
||||
width: 90,
|
||||
fit: 'contain',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '什么是 Mind Elixir',
|
||||
id: 'bd4313fbac40284b',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '一个思维导图内核',
|
||||
id: 'beeb823afd6d2114',
|
||||
},
|
||||
{
|
||||
topic: '免费',
|
||||
id: 'c1f068377de9f3a0',
|
||||
},
|
||||
{
|
||||
topic: '开源',
|
||||
id: 'c1f06d38a09f23ca',
|
||||
},
|
||||
{
|
||||
topic: '无框架依赖',
|
||||
id: 'c1f06e4cbcf16463',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '无需 JavaScript 框架即可使用',
|
||||
id: 'c1f06e4cbcf16464',
|
||||
},
|
||||
{
|
||||
topic: '可插件化',
|
||||
id: 'c1f06e4cbcf16465',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '在你的项目中使用',
|
||||
id: 'c1f1f11a7fbf7550',
|
||||
children: [
|
||||
{
|
||||
topic: "import MindElixir from 'mind-elixir'",
|
||||
id: 'c1f1e245b0a89f9b',
|
||||
},
|
||||
{
|
||||
topic: 'new MindElixir({...}).init(data)',
|
||||
id: 'c1f1ebc7072c8928',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '核心特性',
|
||||
id: 'c1f0723c07b408d7',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '流畅的用户体验',
|
||||
id: 'c1f09612fd89920d',
|
||||
},
|
||||
{
|
||||
topic: '精心设计',
|
||||
id: 'c1f09612fd89920e',
|
||||
},
|
||||
{
|
||||
topic: '移动端友好',
|
||||
id: 'c1f09612fd89920f',
|
||||
},
|
||||
{
|
||||
topic: '轻量级 & 高性能',
|
||||
id: 'c1f09612fd899210',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '高效快捷键',
|
||||
id: 'bd1b66c4b56754d9',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Tab - 创建子节点',
|
||||
id: 'bd1b6892bcab126a',
|
||||
},
|
||||
{
|
||||
topic: 'Enter - 创建同级节点',
|
||||
id: 'bd1b6b632a434b27',
|
||||
},
|
||||
{
|
||||
topic: 'F1 - 居中地图',
|
||||
id: 'bd1b983085187c0a',
|
||||
},
|
||||
{
|
||||
topic: 'F2 - 开始编辑',
|
||||
id: 'bd1b983085187c0b',
|
||||
},
|
||||
{
|
||||
topic: 'Ctrl + C/V - 复制/粘贴',
|
||||
id: 'bd1b983085187c0c',
|
||||
},
|
||||
{
|
||||
topic: 'Ctrl + +/- - 放大/缩小',
|
||||
id: 'bd1b983085187c0d',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '高级功能',
|
||||
id: 'bd1b66c4b56754da',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '支持批量操作',
|
||||
id: 'bd1b6892bcab126b',
|
||||
tags: ['新功能'],
|
||||
},
|
||||
{
|
||||
topic: '撤销 / 重做',
|
||||
id: 'bd1b6b632a434b28',
|
||||
tags: ['新功能'],
|
||||
},
|
||||
{
|
||||
topic: '节点总结',
|
||||
id: 'bd1b983085187c0e',
|
||||
},
|
||||
{
|
||||
topic: '使用 CSS 变量轻松样式化',
|
||||
id: 'bd1b983085187c0f',
|
||||
tags: ['新功能'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '专注模式',
|
||||
id: 'bd1b9b94a9a7a913',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '右键点击并选择专注模式',
|
||||
id: 'bd1bb2ac4bbab458',
|
||||
},
|
||||
{
|
||||
topic: '右键点击并选择取消专注模式',
|
||||
id: 'bd1bb4b14d6697c3',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '左侧菜单',
|
||||
id: 'bd1b9d1816ede134',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '节点分布',
|
||||
id: 'bd1ba11e620c3c1a',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '左侧',
|
||||
id: 'bd1c1cb51e6745d3',
|
||||
},
|
||||
{
|
||||
topic: '右侧',
|
||||
id: 'bd1c1e12fd603ff6',
|
||||
},
|
||||
{
|
||||
topic: '左右两侧',
|
||||
id: 'bd1c1f03def5c97b',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '底部菜单',
|
||||
id: 'bd1ba66996df4ba4',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '全屏',
|
||||
id: 'bd1ba81d9bc95a7e',
|
||||
},
|
||||
{
|
||||
topic: '回到中心',
|
||||
id: 'bd1babdd5c18a7a2',
|
||||
},
|
||||
{
|
||||
topic: '放大',
|
||||
id: 'bd1bae68e0ab186e',
|
||||
},
|
||||
{
|
||||
topic: '缩小',
|
||||
id: 'bd1bb06377439977',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '连接',
|
||||
id: 'bd1beff607711025',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '右键点击并选择连接',
|
||||
id: 'bd1bf320da90046a',
|
||||
},
|
||||
{
|
||||
topic: '点击要连接的目标',
|
||||
id: 'bd1bf6f94ff2e642',
|
||||
},
|
||||
{
|
||||
topic: '使用控制点修改连接',
|
||||
id: 'bd1c0c4a487bd036',
|
||||
},
|
||||
{
|
||||
topic: '双向连接',
|
||||
id: '4da8dbbc7b71be99',
|
||||
},
|
||||
{
|
||||
topic: '也可用。',
|
||||
id: '4da8ded27033a710',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '节点样式',
|
||||
id: 'bd1c217f9d0b20bd',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '字体大小',
|
||||
id: 'bd1c24420cd2c2f5',
|
||||
style: {
|
||||
fontSize: '32px',
|
||||
color: '#3298db',
|
||||
},
|
||||
},
|
||||
{
|
||||
topic: '字体颜色',
|
||||
id: 'bd1c2a59b9a2739c',
|
||||
style: {
|
||||
color: '#c0392c',
|
||||
},
|
||||
},
|
||||
{
|
||||
topic: '背景颜色',
|
||||
id: 'bd1c2de33f057eb4',
|
||||
style: {
|
||||
color: '#bdc3c7',
|
||||
background: '#2c3e50',
|
||||
},
|
||||
},
|
||||
{
|
||||
topic: '添加标签',
|
||||
id: 'bd1cff58364436d0',
|
||||
tags: ['已完成'],
|
||||
},
|
||||
{
|
||||
topic: '添加图标',
|
||||
id: 'bd1d0317f7e8a61a',
|
||||
icons: ['😂'],
|
||||
tags: ['www'],
|
||||
},
|
||||
{
|
||||
topic: '加粗',
|
||||
id: 'bd41fd4ca32322a4',
|
||||
style: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
{
|
||||
topic: '超链接',
|
||||
id: 'bd41fd4ca32322a5',
|
||||
hyperLink: 'https://github.com/ssshooter/mind-elixir-core',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '可拖拽',
|
||||
id: 'bd1f03fee1f63bc6',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '将一个节点拖拽到另一个节点\n前者将成为后者的子节点',
|
||||
id: 'bd1f07c598e729dc',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '导出 & 导入',
|
||||
id: 'beeb7586973430db',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '导出为 SVG',
|
||||
id: 'beeb7a6bec2d68e6',
|
||||
},
|
||||
{
|
||||
topic: '导出为 PNG',
|
||||
id: 'beeb7a6bec2d68e7',
|
||||
tags: ['新功能'],
|
||||
},
|
||||
{
|
||||
topic: '导出 JSON 数据',
|
||||
id: 'beeb784cc189375f',
|
||||
},
|
||||
{
|
||||
topic: '导出为 HTML',
|
||||
id: 'beeb7a6bec2d68f5',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '生态系统',
|
||||
id: 'beeb7586973430dc',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '@mind-elixir/node-menu',
|
||||
id: 'beeb7586973430dd',
|
||||
hyperLink: 'https://github.com/ssshooter/node-menu',
|
||||
},
|
||||
{
|
||||
topic: '@mind-elixir/export-xmind',
|
||||
id: 'beeb7586973430de',
|
||||
hyperLink: 'https://github.com/ssshooter/export-xmind',
|
||||
},
|
||||
{
|
||||
topic: 'mind-elixir-react',
|
||||
id: 'beeb7586973430df',
|
||||
hyperLink: 'https://github.com/ssshooter/mind-elixir-react',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'dangerouslySetInnerHTML',
|
||||
id: 'c00a1cf60baa44f0',
|
||||
style: {
|
||||
background: '#f1c40e',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
topic: 'Katex',
|
||||
id: 'c00a2264f4532611',
|
||||
children: [
|
||||
{
|
||||
topic: '',
|
||||
id: 'c00a2264f4532612',
|
||||
dangerouslySetInnerHTML:
|
||||
'<div class="math math-display"><span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:2.4em;vertical-align:-0.95em;"></span><span class="minner"><span class="mopen delimcenter" style="top:0em;"><span class="delimsizing size1">[</span></span><span class="mord"><span class="mtable"><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.85em;"><span style="top:-3.01em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">x</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.35em;"><span></span></span></span></span></span><span class="arraycolsep" style="width:0.5em;"></span><span class="arraycolsep" style="width:0.5em;"></span><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.85em;"><span style="top:-3.01em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.03588em;">y</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.35em;"><span></span></span></span></span></span></span></span><span class="mclose delimcenter" style="top:0em;"><span class="delimsizing size1">]</span></span></span><span class="mspace" style="margin-right:0.1667em;"></span><span class="minner"><span class="mopen delimcenter" style="top:0em;"><span class="delimsizing size3">[</span></span><span class="mord"><span class="mtable"><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.45em;"><span style="top:-3.61em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">a</span></span></span><span style="top:-2.41em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">b</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.95em;"><span></span></span></span></span></span><span class="arraycolsep" style="width:0.5em;"></span><span class="arraycolsep" style="width:0.5em;"></span><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.45em;"><span style="top:-3.61em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">c</span></span></span><span style="top:-2.41em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">d</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.95em;"><span></span></span></span></span></span></span></span><span class="mclose delimcenter" style="top:0em;"><span class="delimsizing size3">]</span></span></span></span></span></span></span></div>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '代码块',
|
||||
id: 'c00a2264fdaw32612',
|
||||
children: [
|
||||
{
|
||||
topic: '',
|
||||
id: 'c00a2264f4532613',
|
||||
dangerouslySetInnerHTML:
|
||||
'<pre class="language-javascript"><code class="language-javascript"><span class="token keyword">let</span> message <span class="token operator">=</span> <span class="token string">"Hello world"</span>\n<span class="token function">alert</span><span class="token punctuation">(</span>message<span class="token punctuation">)</span></code></pre>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '自定义 Div',
|
||||
id: 'c00a2264f4532615',
|
||||
children: [
|
||||
{
|
||||
topic: '',
|
||||
id: 'c00a2264f4532614',
|
||||
dangerouslySetInnerHTML:
|
||||
'<div><style>.title{font-size:50px}</style><div class="title">标题</div><div style="color: red; font-size: 20px;">你好世界</div></div>',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
direction: 1,
|
||||
},
|
||||
{
|
||||
topic: '主题系统',
|
||||
id: 'bd42dad21aaf6baf',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '内置主题',
|
||||
id: 'bd42e1d0163ebf05',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Latte (浅色)',
|
||||
id: 'bd42e619051878b4',
|
||||
style: {
|
||||
background: '#ffffff',
|
||||
color: '#444446',
|
||||
},
|
||||
},
|
||||
{
|
||||
topic: '深色主题',
|
||||
id: 'bd42e97d7ac35e9a',
|
||||
style: {
|
||||
background: '#252526',
|
||||
color: '#ffffff',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: '自定义 CSS 变量',
|
||||
id: 'bd42e1d0163ebf06',
|
||||
tags: ['灵活'],
|
||||
},
|
||||
{
|
||||
topic: '调色板自定义',
|
||||
id: 'bd42e1d0163ebf07',
|
||||
tags: ['10 种颜色'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
expanded: true,
|
||||
},
|
||||
arrows: [
|
||||
{
|
||||
id: 'ac5fb1df7345e9c4',
|
||||
label: '渲染',
|
||||
from: 'beeb784cc189375f',
|
||||
to: 'beeb7a6bec2d68f5',
|
||||
delta1: {
|
||||
x: 142.8828125,
|
||||
y: -57,
|
||||
},
|
||||
delta2: {
|
||||
x: 146.1171875,
|
||||
y: 45,
|
||||
},
|
||||
bidirectional: false,
|
||||
},
|
||||
{
|
||||
id: '4da8e3367b63b640',
|
||||
label: '双向!',
|
||||
from: '4da8dbbc7b71be99',
|
||||
to: '4da8ded27033a710',
|
||||
delta1: {
|
||||
x: -186,
|
||||
y: 7,
|
||||
},
|
||||
delta2: {
|
||||
x: -155,
|
||||
y: 28,
|
||||
},
|
||||
bidirectional: true,
|
||||
style: {
|
||||
stroke: '#8839ef',
|
||||
labelColor: '#8839ef',
|
||||
strokeWidth: '2',
|
||||
strokeDasharray: '2,5',
|
||||
opacity: '1',
|
||||
},
|
||||
},
|
||||
],
|
||||
summaries: [
|
||||
{
|
||||
id: 'a5e68e6a2ce1b648',
|
||||
parent: 'bd42e1d0163ebf04',
|
||||
start: 0,
|
||||
end: 1,
|
||||
label: '总结',
|
||||
},
|
||||
{
|
||||
id: 'a5e6978f1bc69f4a',
|
||||
parent: 'bd4313fbac40284b',
|
||||
start: 3,
|
||||
end: 5,
|
||||
label: '总结',
|
||||
},
|
||||
],
|
||||
direction: 2,
|
||||
theme: {
|
||||
name: 'Latte',
|
||||
// 更新的调色板,颜色更鲜艳
|
||||
palette: ['#dd7878', '#ea76cb', '#8839ef', '#e64553', '#fe640b', '#df8e1d', '#40a02b', '#209fb5', '#1e66f5', '#7287fd'],
|
||||
// 增强的 CSS 变量,更好的样式控制
|
||||
cssVar: {
|
||||
'--node-gap-x': '30px',
|
||||
'--node-gap-y': '10px',
|
||||
'--main-gap-x': '32px',
|
||||
'--main-gap-y': '12px',
|
||||
'--root-radius': '30px',
|
||||
'--main-radius': '20px',
|
||||
'--root-color': '#ffffff',
|
||||
'--root-bgcolor': '#4c4f69',
|
||||
'--root-border-color': 'rgba(0, 0, 0, 0)',
|
||||
'--main-color': '#444446',
|
||||
'--main-bgcolor': '#ffffff',
|
||||
'--topic-padding': '3px',
|
||||
'--color': '#777777',
|
||||
'--bgcolor': '#f6f6f6',
|
||||
'--selected': '#4dc4ff',
|
||||
'--accent-color': '#e64553',
|
||||
'--panel-color': '#444446',
|
||||
'--panel-bgcolor': '#ffffff',
|
||||
'--panel-border-color': '#eaeaea',
|
||||
'--map-padding': '50px 80px',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default aboutMindElixir
|
||||
|
|
@ -1,623 +0,0 @@
|
|||
import type { MindElixirData, NodeObj } from '../index'
|
||||
import { codeBlock, katexHTML, styledDiv } from './htmlText'
|
||||
|
||||
type NodeObjWithUseMd = NodeObj & { useMd?: boolean }
|
||||
type MindElixirDataWithUseMd = Omit<MindElixirData, 'nodeData'> & {
|
||||
nodeData: NodeObjWithUseMd & {
|
||||
children?: NodeObjWithUseMd[]
|
||||
}
|
||||
}
|
||||
|
||||
const aboutMindElixir: MindElixirDataWithUseMd = {
|
||||
nodeData: {
|
||||
id: 'me-root',
|
||||
topic: 'Mind Elixir',
|
||||
tags: ['Mind Map Core'],
|
||||
children: [
|
||||
{
|
||||
topic: 'logo2',
|
||||
id: '56dae51a90d350a8',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
id: 'use-image',
|
||||
topic: 'mind-elixir',
|
||||
image: {
|
||||
url: 'https://raw.githubusercontent.com/ssshooter/mind-elixir-core/master/images/logo2.png',
|
||||
height: 100,
|
||||
width: 90,
|
||||
fit: 'contain',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'What is Mind Elixir',
|
||||
id: 'bd4313fbac40284b',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'A mind map core',
|
||||
id: 'beeb823afd6d2114',
|
||||
},
|
||||
{
|
||||
topic: 'Free',
|
||||
id: 'c1f068377de9f3a0',
|
||||
},
|
||||
{
|
||||
topic: 'Open-Source',
|
||||
id: 'c1f06d38a09f23ca',
|
||||
},
|
||||
{
|
||||
topic: 'Framework agnostic',
|
||||
id: 'c1f06e4cbcf16463',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Use without JavaScript framework',
|
||||
id: 'c1f06e4cbcf16464',
|
||||
},
|
||||
{
|
||||
topic: 'Pluginable',
|
||||
id: 'c1f06e4cbcf16465',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Use in your own project',
|
||||
id: 'c1f1f11a7fbf7550',
|
||||
children: [
|
||||
{
|
||||
topic: "import MindElixir from 'mind-elixir'",
|
||||
id: 'c1f1e245b0a89f9b',
|
||||
},
|
||||
{
|
||||
topic: 'new MindElixir({...}).init(data)',
|
||||
id: 'c1f1ebc7072c8928',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Key Features',
|
||||
id: 'c1f0723c07b408d7',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Fluent UX',
|
||||
id: 'c1f09612fd89920d',
|
||||
},
|
||||
{
|
||||
topic: 'Well designed',
|
||||
id: 'c1f09612fd89920e',
|
||||
},
|
||||
{
|
||||
topic: 'Mobile friendly',
|
||||
id: 'c1f09612fd89920f',
|
||||
},
|
||||
{
|
||||
topic: 'Lightweight & High performance',
|
||||
id: 'c1f09612fd899210',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Efficient Shortcuts',
|
||||
id: 'bd1b66c4b56754d9',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Tab - Create a child node',
|
||||
id: 'bd1b6892bcab126a',
|
||||
},
|
||||
{
|
||||
topic: 'Enter - Create a sibling node',
|
||||
id: 'bd1b6b632a434b27',
|
||||
},
|
||||
{
|
||||
topic: 'F1 - Center the Map',
|
||||
id: 'bd1b983085187c0a',
|
||||
},
|
||||
{
|
||||
topic: 'F2 - Begin Editing',
|
||||
id: 'bd1b983085187c0b',
|
||||
},
|
||||
{
|
||||
topic: 'Ctrl + C/V - Copy/Paste',
|
||||
id: 'bd1b983085187c0c',
|
||||
},
|
||||
{
|
||||
topic: 'Ctrl + +/- - Zoom In/Out',
|
||||
id: 'bd1b983085187c0d',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Advanced Features',
|
||||
id: 'bd1b66c4b56754da',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Bulk operations supported',
|
||||
id: 'bd1b6892bcab126b',
|
||||
tags: ['New'],
|
||||
},
|
||||
{
|
||||
topic: 'Undo / Redo',
|
||||
id: 'bd1b6b632a434b28',
|
||||
tags: ['New'],
|
||||
},
|
||||
{
|
||||
topic: 'Summarize nodes',
|
||||
id: 'bd1b983085187c0e',
|
||||
},
|
||||
{
|
||||
topic: 'Easily Styling with CSS variables',
|
||||
id: 'bd1b983085187c0f',
|
||||
tags: ['New'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Focus mode',
|
||||
id: 'bd1b9b94a9a7a913',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Right click and select Focus Mode',
|
||||
id: 'bd1bb2ac4bbab458',
|
||||
},
|
||||
{
|
||||
topic: 'Right click and select Cancel Focus Mode',
|
||||
id: 'bd1bb4b14d6697c3',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Left menu',
|
||||
id: 'bd1b9d1816ede134',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Node distribution',
|
||||
id: 'bd1ba11e620c3c1a',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Left',
|
||||
id: 'bd1c1cb51e6745d3',
|
||||
},
|
||||
{
|
||||
topic: 'Right',
|
||||
id: 'bd1c1e12fd603ff6',
|
||||
},
|
||||
{
|
||||
topic: 'Both l & r',
|
||||
id: 'bd1c1f03def5c97b',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Bottom menu',
|
||||
id: 'bd1ba66996df4ba4',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Full screen',
|
||||
id: 'bd1ba81d9bc95a7e',
|
||||
},
|
||||
{
|
||||
topic: 'Return to Center',
|
||||
id: 'bd1babdd5c18a7a2',
|
||||
},
|
||||
{
|
||||
topic: 'Zoom in',
|
||||
id: 'bd1bae68e0ab186e',
|
||||
},
|
||||
{
|
||||
topic: 'Zoom out',
|
||||
id: 'bd1bb06377439977',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Link',
|
||||
id: 'bd1beff607711025',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Right click and select Link',
|
||||
id: 'bd1bf320da90046a',
|
||||
},
|
||||
{
|
||||
topic: 'Click the target you want to link',
|
||||
id: 'bd1bf6f94ff2e642',
|
||||
},
|
||||
{
|
||||
topic: 'Modify link with control points',
|
||||
id: 'bd1c0c4a487bd036',
|
||||
},
|
||||
{
|
||||
topic: 'Bidirectional link is',
|
||||
id: '4da8dbbc7b71be99',
|
||||
},
|
||||
{
|
||||
topic: 'Also available.',
|
||||
id: '4da8ded27033a710',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Node style',
|
||||
id: 'bd1c217f9d0b20bd',
|
||||
direction: 0,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Font Size',
|
||||
id: 'bd1c24420cd2c2f5',
|
||||
style: {
|
||||
fontSize: '32px',
|
||||
color: '#3298db',
|
||||
},
|
||||
},
|
||||
{
|
||||
topic: 'Font Color',
|
||||
id: 'bd1c2a59b9a2739c',
|
||||
style: {
|
||||
color: '#c0392c',
|
||||
},
|
||||
},
|
||||
{
|
||||
topic: 'Background Color',
|
||||
id: 'bd1c2de33f057eb4',
|
||||
style: {
|
||||
color: '#bdc3c7',
|
||||
background: '#2c3e50',
|
||||
},
|
||||
},
|
||||
{
|
||||
topic: 'Add tags',
|
||||
id: 'bd1cff58364436d0',
|
||||
tags: ['Completed'],
|
||||
},
|
||||
{
|
||||
topic: 'Add icons',
|
||||
id: 'bd1d0317f7e8a61a',
|
||||
icons: ['😂'],
|
||||
tags: ['www'],
|
||||
},
|
||||
{
|
||||
topic: 'Bolder',
|
||||
id: 'bd41fd4ca32322a4',
|
||||
style: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
{
|
||||
topic: 'Hyper link',
|
||||
id: 'bd41fd4ca32322a5',
|
||||
hyperLink: 'https://github.com/ssshooter/mind-elixir-core',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Draggable',
|
||||
id: 'bd1f03fee1f63bc6',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Drag a node to another node\nand the former one will become a child node of latter one',
|
||||
id: 'bd1f07c598e729dc',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Export & Import',
|
||||
id: 'beeb7586973430db',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Export as SVG',
|
||||
id: 'beeb7a6bec2d68e6',
|
||||
},
|
||||
{
|
||||
topic: 'Export as PNG',
|
||||
id: 'beeb7a6bec2d68e7',
|
||||
tags: ['New'],
|
||||
},
|
||||
{
|
||||
topic: 'Export JSON data',
|
||||
id: 'beeb784cc189375f',
|
||||
},
|
||||
{
|
||||
topic: 'Export as HTML',
|
||||
id: 'beeb7a6bec2d68f5',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Ecosystem',
|
||||
id: 'beeb7586973430dc',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '@mind-elixir/node-menu',
|
||||
id: 'beeb7586973430dd',
|
||||
hyperLink: 'https://github.com/ssshooter/node-menu',
|
||||
},
|
||||
{
|
||||
topic: '@mind-elixir/export-xmind',
|
||||
id: 'beeb7586973430de',
|
||||
hyperLink: 'https://github.com/ssshooter/export-xmind',
|
||||
},
|
||||
{
|
||||
topic: 'mind-elixir-react',
|
||||
id: 'beeb7586973430df',
|
||||
hyperLink: 'https://github.com/ssshooter/mind-elixir-react',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'dangerouslySetInnerHTML',
|
||||
id: 'c00a1cf60baa44f0',
|
||||
style: {
|
||||
background: '#f1c40e',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
topic: 'Code Block',
|
||||
id: 'c00a2264fdaw32612',
|
||||
children: [
|
||||
{
|
||||
topic: '',
|
||||
id: 'c00a2264f4532613',
|
||||
dangerouslySetInnerHTML:
|
||||
'<pre class="language-javascript"><code class="language-javascript"><span class="token keyword">let</span> message <span class="token operator">=</span> <span class="token string">\'Hello world\'</span>\n<span class="token function">alert</span><span class="token punctuation">(</span>message<span class="token punctuation">)</span></code></pre>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Customized Div',
|
||||
id: 'c00a2264f4532615',
|
||||
children: [
|
||||
{
|
||||
topic: '',
|
||||
id: 'c00a2264f4532614',
|
||||
dangerouslySetInnerHTML:
|
||||
'<div><style>.title{font-size:50px}</style><div class="title">Title</div><div style="color: red; font-size: 20px;">Hello world</div></div>',
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// topic: 'Video',
|
||||
// id: 'c00a2264ffadw19',
|
||||
// children: [
|
||||
// {
|
||||
// topic: '',
|
||||
// id: 'c00a2264f453fv14',
|
||||
// dangerouslySetInnerHTML:
|
||||
// '<iframe src="//player.bilibili.com/player.html?bvid=BV1aTxMehEjK&poster=1&autoplay=0&danmaku=0" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"></iframe>',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
direction: 1,
|
||||
},
|
||||
{
|
||||
topic: 'KaTeX',
|
||||
id: 'markdown-complex-math',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Normal distribution: $$f(x) = \\frac{1}{\\sqrt{2\\pi\\sigma^2}} e^{-\\frac{(x-\\mu)^2}{2\\sigma^2}}$$',
|
||||
id: 'markdown-normal-dist',
|
||||
useMd: true,
|
||||
},
|
||||
{
|
||||
topic: 'Fourier transform: $$F(\\omega) = \\int_{-\\infty}^{\\infty} f(t) e^{-i\\omega t} dt$$',
|
||||
id: 'markdown-fourier',
|
||||
useMd: true,
|
||||
},
|
||||
{
|
||||
topic: 'Taylor series: $$f(x) = \\sum_{n=0}^{\\infty} \\frac{f^{(n)}(a)}{n!}(x-a)^n$$',
|
||||
id: 'markdown-taylor',
|
||||
useMd: true,
|
||||
},
|
||||
{
|
||||
topic: 'Schrödinger equation: $$i\\hbar\\frac{\\partial}{\\partial t}\\Psi = \\hat{H}\\Psi$$',
|
||||
id: 'markdown-schrodinger',
|
||||
useMd: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Basic Markdown Examples',
|
||||
id: 'markdown-basic-examples',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: '# Heading 1',
|
||||
id: 'markdown-headings',
|
||||
useMd: true,
|
||||
},
|
||||
{
|
||||
topic: '**Bold text** and *italic text* and ***bold italic***',
|
||||
id: 'markdown-emphasis',
|
||||
useMd: true,
|
||||
},
|
||||
{
|
||||
topic: '- Unordered list item 1\n- Unordered list item 2',
|
||||
id: 'markdown-lists',
|
||||
useMd: true,
|
||||
},
|
||||
{
|
||||
topic: '[Link to GitHub](https://github.com) and `inline code`',
|
||||
id: 'markdown-links-code',
|
||||
useMd: true,
|
||||
},
|
||||
{
|
||||
topic: '> This is a blockquote\n> with multiple lines',
|
||||
id: 'markdown-blockquote',
|
||||
useMd: true,
|
||||
},
|
||||
{
|
||||
topic: '```javascript\nconst greeting = "Hello World!";\nconsole.log(greeting);\n```',
|
||||
id: 'markdown-code-block',
|
||||
useMd: true,
|
||||
},
|
||||
{
|
||||
topic:
|
||||
'| Column 1 | Column 2 | Column 3 |\n|----------|----------|----------|\n| Row 1 | Data 1 | Info 1 |\n| Row 2 | Data 2 | Info 2 |',
|
||||
id: 'markdown-table',
|
||||
useMd: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Theme System',
|
||||
id: 'bd42dad21aaf6baf',
|
||||
direction: 1,
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Built-in Themes',
|
||||
id: 'bd42e1d0163ebf05',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'Latte (Light)',
|
||||
id: 'bd42e619051878b4',
|
||||
style: {
|
||||
background: '#ffffff',
|
||||
color: '#444446',
|
||||
},
|
||||
},
|
||||
{
|
||||
topic: 'Dark Theme',
|
||||
id: 'bd42e97d7ac35e9a',
|
||||
style: {
|
||||
background: '#252526',
|
||||
color: '#ffffff',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
topic: 'Custom CSS Variables',
|
||||
id: 'bd42e1d0163ebf06',
|
||||
tags: ['Flexible'],
|
||||
},
|
||||
{
|
||||
topic: 'Color Palette Customization',
|
||||
id: 'bd42e1d0163ebf07',
|
||||
tags: ['10 Colors'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
expanded: true,
|
||||
},
|
||||
arrows: [
|
||||
{
|
||||
id: 'ac5fb1df7345e9c4',
|
||||
label: 'Render',
|
||||
from: 'beeb784cc189375f',
|
||||
to: 'beeb7a6bec2d68f5',
|
||||
delta1: {
|
||||
x: 142.8828125,
|
||||
y: -57,
|
||||
},
|
||||
delta2: {
|
||||
x: 146.1171875,
|
||||
y: 45,
|
||||
},
|
||||
bidirectional: false,
|
||||
},
|
||||
{
|
||||
id: '4da8e3367b63b640',
|
||||
label: 'Bidirectional!',
|
||||
from: '4da8dbbc7b71be99',
|
||||
to: '4da8ded27033a710',
|
||||
delta1: {
|
||||
x: -186,
|
||||
y: 7,
|
||||
},
|
||||
delta2: {
|
||||
x: -155,
|
||||
y: 28,
|
||||
},
|
||||
bidirectional: true,
|
||||
style: {
|
||||
stroke: '#8839ef',
|
||||
labelColor: '#8839ef',
|
||||
strokeWidth: '2',
|
||||
strokeDasharray: '2,5',
|
||||
opacity: '1',
|
||||
},
|
||||
},
|
||||
],
|
||||
summaries: [
|
||||
{
|
||||
id: 'a5e68e6a2ce1b648',
|
||||
parent: 'bd42e1d0163ebf04',
|
||||
start: 0,
|
||||
end: 1,
|
||||
label: 'summary',
|
||||
},
|
||||
{
|
||||
id: 'a5e6978f1bc69f4a',
|
||||
parent: 'bd4313fbac40284b',
|
||||
start: 3,
|
||||
end: 5,
|
||||
label: 'summary',
|
||||
},
|
||||
],
|
||||
direction: 2,
|
||||
theme: {
|
||||
name: 'Latte',
|
||||
// Updated color palette with more vibrant colors
|
||||
palette: ['#dd7878', '#ea76cb', '#8839ef', '#e64553', '#fe640b', '#df8e1d', '#40a02b', '#209fb5', '#1e66f5', '#7287fd'],
|
||||
// Enhanced CSS variables for better styling control
|
||||
cssVar: {
|
||||
'--node-gap-x': '30px',
|
||||
'--node-gap-y': '10px',
|
||||
'--main-gap-x': '32px',
|
||||
'--main-gap-y': '12px',
|
||||
'--root-radius': '30px',
|
||||
'--main-radius': '20px',
|
||||
'--root-color': '#ffffff',
|
||||
'--root-bgcolor': '#4c4f69',
|
||||
'--root-border-color': 'rgba(0, 0, 0, 0)',
|
||||
'--main-color': '#444446',
|
||||
'--main-bgcolor': '#ffffff',
|
||||
'--topic-padding': '3px',
|
||||
'--color': '#777777',
|
||||
'--bgcolor': '#f6f6f6',
|
||||
'--selected': '#4dc4ff',
|
||||
'--accent-color': '#e64553',
|
||||
'--panel-color': '#444446',
|
||||
'--panel-bgcolor': '#ffffff',
|
||||
'--panel-border-color': '#eaeaea',
|
||||
'--map-padding': '50px 80px',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default aboutMindElixir as MindElixirData
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import type { MindElixirData } from '../index'
|
||||
import MindElixir from '../index'
|
||||
|
||||
const mindElixirStruct: MindElixirData = {
|
||||
direction: 1,
|
||||
theme: MindElixir.DARK_THEME,
|
||||
nodeData: {
|
||||
id: 'me-root',
|
||||
topic: 'HTML structure',
|
||||
children: [
|
||||
{
|
||||
topic: 'div.map-container',
|
||||
id: '33905a6bde6512e4',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'div.map-canvas',
|
||||
id: '33905d3c66649e8f',
|
||||
tags: ['A special case of a `grp` tag'],
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'me-root',
|
||||
id: '33906b754897b9b9',
|
||||
tags: ['A special case of a `t` tag'],
|
||||
expanded: true,
|
||||
children: [{ topic: 'ME-TPC', id: '33b5cbc93b9968ab' }],
|
||||
},
|
||||
{
|
||||
topic: 'children.box',
|
||||
id: '33906db16ed7f956',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'grp(group)',
|
||||
id: '33907d9a3664cc8a',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 't(top)',
|
||||
id: '3390856d09415b95',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'tpc(topic)',
|
||||
id: '33908dd36c7d32c5',
|
||||
expanded: true,
|
||||
children: [
|
||||
{ topic: 'text', id: '3391630d4227e248' },
|
||||
{ topic: 'icons', id: '33916d74224b141f' },
|
||||
{ topic: 'tags', id: '33916421bfff1543' },
|
||||
],
|
||||
tags: ['E() function return'],
|
||||
},
|
||||
{
|
||||
topic: 'epd(expander)',
|
||||
id: '33909032ed7b5e8e',
|
||||
tags: ['If had child'],
|
||||
},
|
||||
],
|
||||
tags: ['createParent retun'],
|
||||
},
|
||||
{
|
||||
topic: 'me-children',
|
||||
id: '339087e1a8a5ea68',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'me-wrapper',
|
||||
id: '3390930112ea7367',
|
||||
tags: ['what add node actually do is to append grp tag to children'],
|
||||
},
|
||||
{ topic: 'grp...', id: '3390940a8c8380a6' },
|
||||
],
|
||||
tags: ['layoutChildren return'],
|
||||
},
|
||||
{ topic: 'svg.subLines', id: '33908986b6336a4f' },
|
||||
],
|
||||
tags: ['have child'],
|
||||
},
|
||||
{
|
||||
topic: 'me-wrapper',
|
||||
id: '339081c3c5f57756',
|
||||
expanded: true,
|
||||
children: [
|
||||
{
|
||||
topic: 'ME-PARENT',
|
||||
id: '33b6160ec048b997',
|
||||
expanded: true,
|
||||
children: [{ topic: 'ME-TPC', id: '33b616f9fe7763fc' }],
|
||||
},
|
||||
],
|
||||
tags: ['no child'],
|
||||
},
|
||||
{ topic: 'grp...', id: '33b61346707af71a' },
|
||||
],
|
||||
},
|
||||
{ topic: 'svg.lines', id: '3390707d68c0779d' },
|
||||
{ topic: 'svg.linkcontroller', id: '339072cb6cf95295' },
|
||||
{ topic: 'svg.topiclinks', id: '3390751acbdbdb9f' },
|
||||
],
|
||||
},
|
||||
{ topic: 'cmenu', id: '33905f95aeab942d' },
|
||||
{ topic: 'toolbar.rb', id: '339060ac0343f0d7' },
|
||||
{ topic: 'toolbar.lt', id: '3390622b29323de9' },
|
||||
{ topic: 'nmenu', id: '3390645e6d7c2b4e' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
arrows: [],
|
||||
}
|
||||
|
||||
export default mindElixirStruct
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export const katexHTML = `<div class="math math-display"><span class="katex-display"><span class="katex"><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:2.4em;vertical-align:-0.95em;"></span><span class="minner"><span class="mopen delimcenter" style="top:0em;"><span class="delimsizing size1">[</span></span><span class="mord"><span class="mtable"><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.85em;"><span style="top:-3.01em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">x</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.35em;"><span></span></span></span></span></span><span class="arraycolsep" style="width:0.5em;"></span><span class="arraycolsep" style="width:0.5em;"></span><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.85em;"><span style="top:-3.01em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.03588em;">y</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.35em;"><span></span></span></span></span></span></span></span><span class="mclose delimcenter" style="top:0em;"><span class="delimsizing size1">]</span></span></span><span class="mspace" style="margin-right:0.1667em;"></span><span class="minner"><span class="mopen delimcenter" style="top:0em;"><span class="delimsizing size3">[</span></span><span class="mord"><span class="mtable"><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.45em;"><span style="top:-3.61em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">a</span></span></span><span style="top:-2.41em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">b</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.95em;"><span></span></span></span></span></span><span class="arraycolsep" style="width:0.5em;"></span><span class="arraycolsep" style="width:0.5em;"></span><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.45em;"><span style="top:-3.61em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">c</span></span></span><span style="top:-2.41em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">d</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.95em;"><span></span></span></span></span></span></span></span><span class="mclose delimcenter" style="top:0em;"><span class="delimsizing size3">]</span></span></span></span></span></span></span></div>`
|
||||
|
||||
export const codeBlock = `<pre class="language-javascript"><code class="language-javascript"><span class="token keyword">let</span> message <span class="token operator">=</span> <span class="token string">'Hello world'</span>
|
||||
<span class="token function">alert</span><span class="token punctuation">(</span>message<span class="token punctuation">)</span></code></pre>`
|
||||
|
||||
export const styledDiv = `<div><style>.title{font-size:50px}</style><div class="title">Title</div><div style="color: red; font-size: 20px;">Hello world</div></div>`
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
type LangPack = {
|
||||
addChild: string
|
||||
addParent: string
|
||||
addSibling: string
|
||||
removeNode: string
|
||||
focus: string
|
||||
cancelFocus: string
|
||||
moveUp: string
|
||||
moveDown: string
|
||||
link: string
|
||||
linkBidirectional: string
|
||||
clickTips: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type Locale = 'cn' | 'zh_CN' | 'zh_TW' | 'en' | 'ru' | 'ja' | 'pt' | 'it' | 'es' | 'fr' | 'ko'
|
||||
const cn = {
|
||||
addChild: '插入子节点',
|
||||
addParent: '插入父节点',
|
||||
addSibling: '插入同级节点',
|
||||
removeNode: '删除节点',
|
||||
focus: '专注',
|
||||
cancelFocus: '取消专注',
|
||||
moveUp: '上移',
|
||||
moveDown: '下移',
|
||||
link: '连接',
|
||||
linkBidirectional: '双向连接',
|
||||
clickTips: '请点击目标节点',
|
||||
summary: '摘要',
|
||||
}
|
||||
const i18n: Record<Locale, LangPack> = {
|
||||
cn,
|
||||
zh_CN: cn,
|
||||
zh_TW: {
|
||||
addChild: '插入子節點',
|
||||
addParent: '插入父節點',
|
||||
addSibling: '插入同級節點',
|
||||
removeNode: '刪除節點',
|
||||
focus: '專注',
|
||||
cancelFocus: '取消專注',
|
||||
moveUp: '上移',
|
||||
moveDown: '下移',
|
||||
link: '連接',
|
||||
linkBidirectional: '雙向連接',
|
||||
clickTips: '請點擊目標節點',
|
||||
summary: '摘要',
|
||||
},
|
||||
en: {
|
||||
addChild: 'Add child',
|
||||
addParent: 'Add parent',
|
||||
addSibling: 'Add sibling',
|
||||
removeNode: 'Remove node',
|
||||
focus: 'Focus Mode',
|
||||
cancelFocus: 'Cancel Focus Mode',
|
||||
moveUp: 'Move up',
|
||||
moveDown: 'Move down',
|
||||
link: 'Link',
|
||||
linkBidirectional: 'Bidirectional Link',
|
||||
clickTips: 'Please click the target node',
|
||||
summary: 'Summary',
|
||||
},
|
||||
ru: {
|
||||
addChild: 'Добавить дочерний элемент',
|
||||
addParent: 'Добавить родительский элемент',
|
||||
addSibling: 'Добавить на этом уровне',
|
||||
removeNode: 'Удалить узел',
|
||||
focus: 'Режим фокусировки',
|
||||
cancelFocus: 'Отменить режим фокусировки',
|
||||
moveUp: 'Поднять выше',
|
||||
moveDown: 'Опустить ниже',
|
||||
link: 'Ссылка',
|
||||
linkBidirectional: 'Двунаправленная ссылка',
|
||||
clickTips: 'Пожалуйста, нажмите на целевой узел',
|
||||
summary: 'Описание',
|
||||
},
|
||||
ja: {
|
||||
addChild: '子ノードを追加する',
|
||||
addParent: '親ノードを追加します',
|
||||
addSibling: '兄弟ノードを追加する',
|
||||
removeNode: 'ノードを削除',
|
||||
focus: '集中',
|
||||
cancelFocus: '集中解除',
|
||||
moveUp: '上へ移動',
|
||||
moveDown: '下へ移動',
|
||||
link: 'コネクト',
|
||||
linkBidirectional: '双方向リンク',
|
||||
clickTips: 'ターゲットノードをクリックしてください',
|
||||
summary: '概要',
|
||||
},
|
||||
pt: {
|
||||
addChild: 'Adicionar item filho',
|
||||
addParent: 'Adicionar item pai',
|
||||
addSibling: 'Adicionar item irmao',
|
||||
removeNode: 'Remover item',
|
||||
focus: 'Modo Foco',
|
||||
cancelFocus: 'Cancelar Modo Foco',
|
||||
moveUp: 'Mover para cima',
|
||||
moveDown: 'Mover para baixo',
|
||||
link: 'Link',
|
||||
linkBidirectional: 'Link bidirecional',
|
||||
clickTips: 'Favor clicar no item alvo',
|
||||
summary: 'Resumo',
|
||||
},
|
||||
it: {
|
||||
addChild: 'Aggiungi figlio',
|
||||
addParent: 'Aggiungi genitore',
|
||||
addSibling: 'Aggiungi fratello',
|
||||
removeNode: 'Rimuovi nodo',
|
||||
focus: 'Modalità Focus',
|
||||
cancelFocus: 'Annulla Modalità Focus',
|
||||
moveUp: 'Sposta su',
|
||||
moveDown: 'Sposta giù',
|
||||
link: 'Collega',
|
||||
linkBidirectional: 'Collegamento bidirezionale',
|
||||
clickTips: 'Si prega di fare clic sul nodo di destinazione',
|
||||
summary: 'Unisci nodi',
|
||||
},
|
||||
es: {
|
||||
addChild: 'Agregar hijo',
|
||||
addParent: 'Agregar padre',
|
||||
addSibling: 'Agregar hermano',
|
||||
removeNode: 'Eliminar nodo',
|
||||
focus: 'Modo Enfoque',
|
||||
cancelFocus: 'Cancelar Modo Enfoque',
|
||||
moveUp: 'Mover hacia arriba',
|
||||
moveDown: 'Mover hacia abajo',
|
||||
link: 'Enlace',
|
||||
linkBidirectional: 'Enlace bidireccional',
|
||||
clickTips: 'Por favor haga clic en el nodo de destino',
|
||||
summary: 'Resumen',
|
||||
},
|
||||
fr: {
|
||||
addChild: 'Ajout enfant',
|
||||
addParent: 'Ajout parent',
|
||||
addSibling: 'Ajout voisin',
|
||||
removeNode: 'Supprimer',
|
||||
focus: 'Cibler',
|
||||
cancelFocus: 'Retour',
|
||||
moveUp: 'Monter',
|
||||
moveDown: 'Descendre',
|
||||
link: 'Lier',
|
||||
linkBidirectional: 'Lien bidirectionnel',
|
||||
clickTips: 'Cliquer sur le noeud cible',
|
||||
summary: 'Annoter',
|
||||
},
|
||||
ko: {
|
||||
addChild: '자식 추가',
|
||||
addParent: '부모 추가',
|
||||
addSibling: '형제 추가',
|
||||
removeNode: '노드 삭제',
|
||||
focus: '포커스 모드',
|
||||
cancelFocus: '포커스 모드 취소',
|
||||
moveUp: '위로 이동',
|
||||
moveDown: '아래로 이동',
|
||||
link: '연결',
|
||||
linkBidirectional: '양방향 연결',
|
||||
clickTips: '대상 노드를 클릭하십시오',
|
||||
summary: '요약',
|
||||
},
|
||||
}
|
||||
|
||||
export default i18n
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg t="1656654717242" class="icon" viewBox="0 0 1024 1024" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
|
||||
<path d="M512 74.666667C270.933333 74.666667 74.666667 270.933333 74.666667 512S270.933333 949.333333 512 949.333333 949.333333 753.066667 949.333333 512 753.066667 74.666667 512 74.666667z" stroke-width="54" stroke='black' fill='white' ></path>
|
||||
<path d="M682.666667 480h-138.666667V341.333333c0-17.066667-14.933333-32-32-32s-32 14.933333-32 32v138.666667H341.333333c-17.066667 0-32 14.933333-32 32s14.933333 32 32 32h138.666667V682.666667c0 17.066667 14.933333 32 32 32s32-14.933333 32-32v-138.666667H682.666667c17.066667 0 32-14.933333 32-32s-14.933333-32-32-32z"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 910 B |
|
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1750169402629" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2170" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M639.328 416c8.032 0 16.096-3.008 22.304-9.056l202.624-197.184-0.8 143.808c-0.096 17.696 14.144 32.096 31.808 32.192 0.064 0 0.128 0 0.192 0 17.6 0 31.904-14.208 32-31.808l1.248-222.208c0-0.672-0.352-1.248-0.384-1.92 0.032-0.512 0.288-0.896 0.288-1.408 0.032-17.664-14.272-32-31.968-32.032L671.552 96l-0.032 0c-17.664 0-31.968 14.304-32 31.968C639.488 145.632 653.824 160 671.488 160l151.872 0.224-206.368 200.8c-12.672 12.32-12.928 32.608-0.64 45.248C622.656 412.736 630.976 416 639.328 416z" p-id="2171"></path><path d="M896.032 639.552 896.032 639.552c-17.696 0-32 14.304-32.032 31.968l-0.224 151.872-200.832-206.4c-12.32-12.64-32.576-12.96-45.248-0.64-12.672 12.352-12.928 32.608-0.64 45.248l197.184 202.624-143.808-0.8c-0.064 0-0.128 0-0.192 0-17.6 0-31.904 14.208-32 31.808-0.096 17.696 14.144 32.096 31.808 32.192l222.24 1.248c0.064 0 0.128 0 0.192 0 0.64 0 1.12-0.32 1.76-0.352 0.512 0.032 0.896 0.288 1.408 0.288l0.032 0c17.664 0 31.968-14.304 32-31.968L928 671.584C928.032 653.952 913.728 639.584 896.032 639.552z" p-id="2172"></path><path d="M209.76 159.744l143.808 0.8c0.064 0 0.128 0 0.192 0 17.6 0 31.904-14.208 32-31.808 0.096-17.696-14.144-32.096-31.808-32.192L131.68 95.328c-0.064 0-0.128 0-0.192 0-0.672 0-1.248 0.352-1.888 0.384-0.448 0-0.8-0.256-1.248-0.256 0 0-0.032 0-0.032 0-17.664 0-31.968 14.304-32 31.968L96 352.448c-0.032 17.664 14.272 32 31.968 32.032 0 0 0.032 0 0.032 0 17.664 0 31.968-14.304 32-31.968l0.224-151.936 200.832 206.4c6.272 6.464 14.624 9.696 22.944 9.696 8.032 0 16.096-3.008 22.304-9.056 12.672-12.32 12.96-32.608 0.64-45.248L209.76 159.744z" p-id="2173"></path><path d="M362.368 617.056l-202.624 197.184 0.8-143.808c0.096-17.696-14.144-32.096-31.808-32.192-0.064 0-0.128 0-0.192 0-17.6 0-31.904 14.208-32 31.808l-1.248 222.24c0 0.704 0.352 1.312 0.384 2.016 0 0.448-0.256 0.832-0.256 1.312-0.032 17.664 14.272 32 31.968 32.032L352.448 928c0 0 0.032 0 0.032 0 17.664 0 31.968-14.304 32-31.968s-14.272-32-31.968-32.032l-151.936-0.224 206.4-200.832c12.672-12.352 12.96-32.608 0.64-45.248S375.008 604.704 362.368 617.056z" p-id="2174"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
|
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1750169375313" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1775" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M639 463.30000001L639 285.1c0-36.90000001-26.4-68.5-61.3-68.5l-150.2 0c-1.5 0-3 0.1-4.5 0.3-10.2-38.7-45.5-67.3-87.5-67.3-50 0-90.5 40.5-90.5 90.5s40.5 90.5 90.5 90.5c42 0 77.3-28.6 87.5-67.39999999 1.4 0.3 2.9 0.4 4.5 0.39999999L577.7 263.6c6.8 0 14.3 8.9 14.3 21.49999999l0 427.00000001c0 12.7-7.40000001 21.5-14.30000001 21.5l-150.19999999 0c-1.5 0-3 0.2-4.5 0.4-10.2-38.8-45.5-67.3-87.5-67.3-50 0-90.5 40.5-90.5 90.4 0 49.9 40.5 90.6 90.5 90.59999999 42 0 77.3-28.6 87.5-67.39999999 1.4 0.2 2.9 0.4 4.49999999 0.4L577.7 780.7c34.80000001 0 61.3-31.6 61.3-68.50000001L639 510.3l79.1 0c10.4 38.5 45.49999999 67 87.4 67 50 0 90.5-40.5 90.5-90.5s-40.5-90.5-90.5-90.5c-41.79999999 0-77.00000001 28.4-87.4 67L639 463.30000001z" fill="currentColor" p-id="1776"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1750169573443" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2883" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M514.133333 488.533333m-106.666666 0a106.666667 106.666667 0 1 0 213.333333 0 106.666667 106.666667 0 1 0-213.333333 0Z" fill="currentColor" p-id="2884"></path><path d="M512 64C264.533333 64 64 264.533333 64 512c0 236.8 183.466667 428.8 416 445.866667v-134.4c-53.333333-59.733333-200.533333-230.4-200.533333-334.933334 0-130.133333 104.533333-234.666667 234.666666-234.666666s234.666667 104.533333 234.666667 234.666666c0 61.866667-49.066667 153.6-145.066667 270.933334l-59.733333 68.266666V960C776.533333 942.933333 960 748.8 960 512c0-247.466667-200.533333-448-448-448z" fill="currentColor" p-id="2885"></path></svg>
|
||||
|
Before Width: | Height: | Size: 951 B |
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg t="1656655564985" class="icon" viewBox="0 0 1024 1024" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
|
||||
<path d="M512 74.666667C270.933333 74.666667 74.666667 270.933333 74.666667 512S270.933333 949.333333 512 949.333333 949.333333 753.066667 949.333333 512 753.066667 74.666667 512 74.666667z" stroke-width="54" stroke='black' fill='white' ></path>
|
||||
<path d="M682.666667 544H341.333333c-17.066667 0-32-14.933333-32-32s14.933333-32 32-32h341.333334c17.066667 0 32 14.933333 32 32s-14.933333 32-32 32z"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 741 B |
|
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1750169667709" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3037" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M385 560.69999999L385 738.9c0 36.90000001 26.4 68.5 61.3 68.5l150.2 0c1.5 0 3-0.1 4.5-0.3 10.2 38.7 45.5 67.3 87.5 67.3 50 0 90.5-40.5 90.5-90.5s-40.5-90.5-90.5-90.5c-42 0-77.3 28.6-87.5 67.39999999-1.4-0.3-2.9-0.4-4.5-0.39999999L446.3 760.4c-6.8 0-14.3-8.9-14.3-21.49999999l0-427.00000001c0-12.7 7.40000001-21.5 14.30000001-21.5l150.19999999 0c1.5 0 3-0.2 4.5-0.4 10.2 38.8 45.5 67.3 87.5 67.3 50 0 90.5-40.5 90.5-90.4 0-49.9-40.5-90.6-90.5-90.59999999-42 0-77.3 28.6-87.5 67.39999999-1.4-0.2-2.9-0.4-4.49999999-0.4L446.3 243.3c-34.80000001 0-61.3 31.6-61.3 68.50000001L385 513.7l-79.1 0c-10.4-38.5-45.49999999-67-87.4-67-50 0-90.5 40.5-90.5 90.5s40.5 90.5 90.5 90.5c41.79999999 0 77.00000001-28.4 87.4-67L385 560.69999999z" fill="currentColor" p-id="3038"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1750169394918" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2021" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M851.91168 328.45312c-59.97056 0-108.6208 48.47104-108.91264 108.36992l-137.92768 38.4a109.14304 109.14304 0 0 0-63.46752-46.58688l1.39264-137.11872c47.29344-11.86816 82.31936-54.66624 82.31936-105.64096 0-60.15488-48.76288-108.91776-108.91776-108.91776s-108.91776 48.76288-108.91776 108.91776c0 49.18784 32.60928 90.75712 77.38368 104.27392l-1.41312 138.87488a109.19936 109.19936 0 0 0-63.50336 48.55808l-138.93632-39.48544 0.01024-0.72704c0-60.15488-48.76288-108.91776-108.91776-108.91776s-108.91776 48.75776-108.91776 108.91776c0 60.15488 48.76288 108.91264 108.91776 108.91264 39.3984 0 73.91232-20.92032 93.03552-52.2496l139.19232 39.552-0.00512 0.2304c0 25.8304 9.00096 49.5616 24.02816 68.23424l-90.14272 132.63872a108.7488 108.7488 0 0 0-34.2528-5.504c-60.15488 0-108.91776 48.768-108.91776 108.91776 0 60.16 48.76288 108.91776 108.91776 108.91776 60.16 0 108.92288-48.75776 108.92288-108.91776 0-27.14624-9.9328-51.968-26.36288-71.04l89.04704-131.03104a108.544 108.544 0 0 0 37.6832 6.70208 108.672 108.672 0 0 0 36.48512-6.272l93.13792 132.57216a108.48256 108.48256 0 0 0-24.69888 69.0688c0 60.16 48.768 108.92288 108.91776 108.92288 60.16 0 108.91776-48.76288 108.91776-108.92288 0-60.14976-48.75776-108.91776-108.91776-108.91776a108.80512 108.80512 0 0 0-36.69504 6.3488l-93.07136-132.48a108.48768 108.48768 0 0 0 24.79616-72.22784l136.09984-37.888c18.99008 31.93856 53.84192 53.3504 93.69088 53.3504 60.16 0 108.92288-48.75776 108.92288-108.91264-0.00512-60.15488-48.77312-108.92288-108.92288-108.92288z" p-id="2022"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1750169419447" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2480" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M863.328 482.56l-317.344-1.12L545.984 162.816c0-17.664-14.336-32-32-32s-32 14.336-32 32l0 318.4L159.616 480.064c-0.032 0-0.064 0-0.096 0-17.632 0-31.936 14.24-32 31.904C127.424 529.632 141.728 544 159.392 544.064l322.592 1.152 0 319.168c0 17.696 14.336 32 32 32s32-14.304 32-32l0-318.944 317.088 1.12c0.064 0 0.096 0 0.128 0 17.632 0 31.936-14.24 32-31.904C895.264 496.992 880.96 482.624 863.328 482.56z" p-id="2481"></path></svg>
|
||||
|
Before Width: | Height: | Size: 763 B |
|
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1750169426515" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2730" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M863.744 544 163.424 544c-17.664 0-32-14.336-32-32s14.336-32 32-32l700.32 0c17.696 0 32 14.336 32 32S881.44 544 863.744 544z" p-id="2731"></path></svg>
|
||||
|
Before Width: | Height: | Size: 484 B |
|
|
@ -1,374 +0,0 @@
|
|||
.map-container {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC,
|
||||
WenQuanYi Micro Hei, sans-serif;
|
||||
user-select: none;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden; // prevent browser scroll container while dragging
|
||||
font-size: 15px;
|
||||
outline: none; // prevent browser default focus outline
|
||||
touch-action: none; // use pointer events instead of touch events
|
||||
* {
|
||||
box-sizing: border-box; // must have, to make getComputedStyle work right
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
}
|
||||
.selected {
|
||||
outline: 2px solid var(--selected);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.hyper-link {
|
||||
text-decoration: none;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
me-main > me-wrapper > me-parent > me-epd {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
me-epd {
|
||||
top: 100%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.lhs {
|
||||
direction: rtl;
|
||||
& > me-wrapper > me-parent > me-epd {
|
||||
left: -10px;
|
||||
}
|
||||
me-epd {
|
||||
left: 5px;
|
||||
}
|
||||
me-tpc {
|
||||
direction: ltr;
|
||||
}
|
||||
}
|
||||
.rhs {
|
||||
& > me-wrapper > me-parent > me-epd {
|
||||
right: -10px;
|
||||
}
|
||||
me-epd {
|
||||
right: 5px;
|
||||
}
|
||||
}
|
||||
background-color: var(--bgcolor);
|
||||
.map-canvas {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
width: fit-content; // 100% width if not set
|
||||
transform: scale(1);
|
||||
me-nodes {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: max-content;
|
||||
width: max-content;
|
||||
padding: var(--map-padding);
|
||||
}
|
||||
}
|
||||
me-main {
|
||||
// primary node / main node
|
||||
& > me-wrapper {
|
||||
position: relative; // make subline svg's offsetParent be the main node
|
||||
margin: var(--main-gap-y) var(--main-gap-x);
|
||||
& > me-parent {
|
||||
margin: 10px;
|
||||
padding: 0;
|
||||
& > me-tpc {
|
||||
border-radius: var(--main-radius);
|
||||
background-color: var(--main-bgcolor);
|
||||
border: 2px solid var(--main-color);
|
||||
color: var(--main-color);
|
||||
padding: 8px 25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
me-wrapper {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
width: fit-content;
|
||||
// position: relative;
|
||||
}
|
||||
me-children,
|
||||
me-parent {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
me-root {
|
||||
position: relative;
|
||||
margin: 45px 0;
|
||||
z-index: 10;
|
||||
me-tpc {
|
||||
font-size: 25px;
|
||||
color: var(--root-color);
|
||||
padding: 10px 30px;
|
||||
border-radius: var(--root-radius);
|
||||
border: var(--root-border-color) 2px solid;
|
||||
background-color: var(--root-bgcolor);
|
||||
}
|
||||
}
|
||||
me-parent {
|
||||
position: relative; // for locating expand button
|
||||
cursor: pointer;
|
||||
padding: 6px var(--node-gap-x);
|
||||
margin-top: var(--node-gap-y);
|
||||
z-index: 10;
|
||||
me-tpc {
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
color: var(--color);
|
||||
padding: var(--topic-padding);
|
||||
|
||||
// drag preview
|
||||
.insert-preview {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0px;
|
||||
z-index: 9;
|
||||
}
|
||||
.show {
|
||||
background: #7ad5ff;
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.before {
|
||||
height: 14px;
|
||||
top: -14px;
|
||||
}
|
||||
.in {
|
||||
height: 100%;
|
||||
top: 0px;
|
||||
}
|
||||
.after {
|
||||
height: 14px;
|
||||
bottom: -14px;
|
||||
}
|
||||
}
|
||||
me-epd {
|
||||
position: absolute;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
opacity: 0.8;
|
||||
background-image: url('./icons/add-circle.svg');
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
|
||||
pointer-events: all;
|
||||
z-index: 9;
|
||||
&.minus {
|
||||
background-image: url('./icons/minus-circle.svg') !important;
|
||||
transition: opacity 0.3s;
|
||||
opacity: 0;
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
@media (hover: none) {
|
||||
& {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// iconfont
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lines,
|
||||
.summary,
|
||||
.subLines,
|
||||
.topiclinks,
|
||||
.linkcontroller {
|
||||
position: absolute;
|
||||
height: 102%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.topiclinks,
|
||||
.linkcontroller,
|
||||
.summary {
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.summary > g,
|
||||
.topiclinks > g {
|
||||
cursor: pointer;
|
||||
pointer-events: stroke;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.lines,
|
||||
.subLines {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#input-box {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: max-content; // let words expand the div and keep max length at the same time
|
||||
max-width: 35em;
|
||||
direction: ltr;
|
||||
user-select: auto;
|
||||
pointer-events: auto;
|
||||
color: var(--color);
|
||||
background-color: var(--bgcolor);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
me-tpc {
|
||||
display: block;
|
||||
max-width: 35em;
|
||||
white-space: pre-wrap;
|
||||
pointer-events: all;
|
||||
& > * {
|
||||
// tags,icons,images should not response to click event
|
||||
pointer-events: none;
|
||||
}
|
||||
& > a,
|
||||
& > iframe {
|
||||
pointer-events: auto;
|
||||
}
|
||||
& > .text {
|
||||
display: inline-block;
|
||||
// Allow links inside markdown text to be clickable
|
||||
a {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
& > img {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
.circle {
|
||||
position: absolute;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
margin-top: -5px;
|
||||
margin-left: -5px;
|
||||
border-radius: 100%;
|
||||
background: #757575;
|
||||
border: 2px solid #ffffff;
|
||||
z-index: 50;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tags {
|
||||
direction: ltr;
|
||||
span {
|
||||
display: inline-block;
|
||||
border-radius: 3px;
|
||||
padding: 2px 4px;
|
||||
background: #d6f0f8;
|
||||
color: #276f86;
|
||||
margin: 0px;
|
||||
font-size: 12px;
|
||||
line-height: 1.3em;
|
||||
margin-right: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
.icons {
|
||||
display: inline-block;
|
||||
direction: ltr;
|
||||
margin-left: 5px;
|
||||
span {
|
||||
display: inline-block;
|
||||
line-height: 1.3em;
|
||||
}
|
||||
}
|
||||
|
||||
.mind-elixir-ghost {
|
||||
position: fixed;
|
||||
top: -100%;
|
||||
left: -100%;
|
||||
box-sizing: content-box;
|
||||
opacity: 0.5;
|
||||
background-color: var(--main-bgcolor);
|
||||
border: 2px solid var(--main-color);
|
||||
color: var(--main-color);
|
||||
max-width: 200px;
|
||||
width: fit-content;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.selection-area {
|
||||
background: #4f90f22d;
|
||||
border: 1px solid #4f90f2;
|
||||
}
|
||||
|
||||
/* Markdown表格样式 */
|
||||
.markdown-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
font-size: 11px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
background-color: #fafafa;
|
||||
overflow: hidden;
|
||||
white-space: normal !important; /* 覆盖MindElixir的pre-wrap */
|
||||
}
|
||||
|
||||
.markdown-table th,
|
||||
.markdown-table td {
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
position: relative;
|
||||
white-space: normal !important; /* 覆盖MindElixir的pre-wrap */
|
||||
}
|
||||
|
||||
.markdown-table th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #d0d0d0;
|
||||
}
|
||||
|
||||
.markdown-table td {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.markdown-table tr:nth-child(even) td {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.markdown-table tr:hover td {
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
|
||||
/* 移除多余的边框,保持简洁 */
|
||||
.markdown-table th:not(:last-child),
|
||||
.markdown-table td:not(:last-child) {
|
||||
border-right: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.markdown-table tr:not(:last-child) td {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
import './index.less'
|
||||
import { LEFT, RIGHT, SIDE, DARK_THEME, THEME } from './const'
|
||||
import { generateUUID } from './utils/index'
|
||||
import initMouseEvent from './mouse'
|
||||
import { createBus } from './utils/pubsub'
|
||||
import { findEle } from './utils/dom'
|
||||
import { createLinkSvg, createLine } from './utils/svg'
|
||||
import type { MindElixirData, MindElixirInstance, MindElixirMethods, Options } from './types/index'
|
||||
import methods from './methods'
|
||||
import { sub, main } from './utils/generateBranch'
|
||||
import { version } from '../package.json'
|
||||
import { createDragMoveHelper } from './utils/dragMoveHelper'
|
||||
import type { Topic } from './docs'
|
||||
|
||||
// TODO show up animation
|
||||
const $d = document
|
||||
|
||||
function MindElixir(
|
||||
this: MindElixirInstance,
|
||||
{
|
||||
el,
|
||||
direction,
|
||||
locale,
|
||||
draggable,
|
||||
editable,
|
||||
contextMenu,
|
||||
toolBar,
|
||||
keypress,
|
||||
mouseSelectionButton,
|
||||
selectionContainer,
|
||||
before,
|
||||
newTopicName,
|
||||
allowUndo,
|
||||
generateMainBranch,
|
||||
generateSubBranch,
|
||||
overflowHidden,
|
||||
theme,
|
||||
alignment,
|
||||
scaleSensitivity,
|
||||
scaleMax,
|
||||
scaleMin,
|
||||
handleWheel,
|
||||
markdown,
|
||||
imageProxy,
|
||||
}: Options
|
||||
): void {
|
||||
let ele: HTMLElement | null = null
|
||||
const elType = Object.prototype.toString.call(el)
|
||||
if (elType === '[object HTMLDivElement]') {
|
||||
ele = el as HTMLElement
|
||||
} else if (elType === '[object String]') {
|
||||
ele = document.querySelector(el as string) as HTMLElement
|
||||
}
|
||||
if (!ele) throw new Error('MindElixir: el is not a valid element')
|
||||
|
||||
ele.style.position = 'relative'
|
||||
ele.innerHTML = ''
|
||||
this.el = ele as HTMLElement
|
||||
this.disposable = []
|
||||
this.before = before || {}
|
||||
this.locale = locale || 'en'
|
||||
this.newTopicName = newTopicName || 'New Node'
|
||||
this.contextMenu = contextMenu ?? true
|
||||
this.toolBar = toolBar ?? true
|
||||
this.keypress = keypress ?? true
|
||||
this.mouseSelectionButton = mouseSelectionButton ?? 0
|
||||
this.direction = direction ?? 1
|
||||
this.draggable = draggable ?? true
|
||||
this.editable = editable ?? true
|
||||
this.allowUndo = allowUndo ?? true
|
||||
this.scaleSensitivity = scaleSensitivity ?? 0.1
|
||||
this.scaleMax = scaleMax ?? 1.4
|
||||
this.scaleMin = scaleMin ?? 0.2
|
||||
this.generateMainBranch = generateMainBranch || main
|
||||
this.generateSubBranch = generateSubBranch || sub
|
||||
this.overflowHidden = overflowHidden ?? false
|
||||
this.alignment = alignment ?? 'root'
|
||||
this.handleWheel = handleWheel ?? true
|
||||
this.markdown = markdown || undefined // Custom markdown parser function
|
||||
this.imageProxy = imageProxy || undefined // Image proxy function
|
||||
// this.parentMap = {} // deal with large amount of nodes
|
||||
this.currentNodes = [] // selected <tpc/> elements
|
||||
this.currentArrow = null // the selected link svg element
|
||||
this.scaleVal = 1
|
||||
this.tempDirection = null
|
||||
|
||||
this.dragMoveHelper = createDragMoveHelper(this)
|
||||
this.bus = createBus()
|
||||
|
||||
this.container = $d.createElement('div') // map container
|
||||
this.selectionContainer = selectionContainer || this.container
|
||||
|
||||
this.container.className = 'map-container'
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
this.theme = theme || (mediaQuery.matches ? DARK_THEME : THEME)
|
||||
|
||||
// infrastructure
|
||||
const canvas = $d.createElement('div') // map-canvas Element
|
||||
canvas.className = 'map-canvas'
|
||||
this.map = canvas
|
||||
this.container.setAttribute('tabindex', '0')
|
||||
this.container.appendChild(this.map)
|
||||
this.el.appendChild(this.container)
|
||||
|
||||
this.nodes = $d.createElement('me-nodes')
|
||||
|
||||
this.lines = createLinkSvg('lines') // main link container
|
||||
this.summarySvg = createLinkSvg('summary') // summary container
|
||||
|
||||
this.linkController = createLinkSvg('linkcontroller') // bezier controller container
|
||||
this.P2 = $d.createElement('div') // bezier P2
|
||||
this.P3 = $d.createElement('div') // bezier P3
|
||||
this.P2.className = this.P3.className = 'circle'
|
||||
this.P2.style.display = this.P3.style.display = 'none'
|
||||
this.line1 = createLine() // bezier auxiliary line1
|
||||
this.line2 = createLine() // bezier auxiliary line2
|
||||
this.linkController.appendChild(this.line1)
|
||||
this.linkController.appendChild(this.line2)
|
||||
this.linkSvgGroup = createLinkSvg('topiclinks') // storage user custom link svg
|
||||
|
||||
this.map.appendChild(this.nodes)
|
||||
|
||||
if (this.overflowHidden) {
|
||||
this.container.style.overflow = 'hidden'
|
||||
} else {
|
||||
this.disposable.push(initMouseEvent(this))
|
||||
}
|
||||
}
|
||||
|
||||
MindElixir.prototype = methods
|
||||
|
||||
Object.defineProperty(MindElixir.prototype, 'currentNode', {
|
||||
get() {
|
||||
return this.currentNodes[this.currentNodes.length - 1]
|
||||
},
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
MindElixir.LEFT = LEFT
|
||||
MindElixir.RIGHT = RIGHT
|
||||
MindElixir.SIDE = SIDE
|
||||
|
||||
MindElixir.THEME = THEME
|
||||
MindElixir.DARK_THEME = DARK_THEME
|
||||
|
||||
/**
|
||||
* @memberof MindElixir
|
||||
* @static
|
||||
*/
|
||||
MindElixir.version = version
|
||||
/**
|
||||
* @function
|
||||
* @memberof MindElixir
|
||||
* @static
|
||||
* @name E
|
||||
* @param {string} id Node id.
|
||||
* @return {TargetElement} Target element.
|
||||
* @example
|
||||
* E('bd4313fbac40284b')
|
||||
*/
|
||||
MindElixir.E = findEle
|
||||
|
||||
/**
|
||||
* @function new
|
||||
* @memberof MindElixir
|
||||
* @static
|
||||
* @param {String} topic root topic
|
||||
*/
|
||||
if (import.meta.env.MODE !== 'lite') {
|
||||
MindElixir.new = (topic: string): MindElixirData => ({
|
||||
nodeData: {
|
||||
id: generateUUID(),
|
||||
topic: topic || 'new topic',
|
||||
children: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface MindElixirCtor {
|
||||
new (options: Options): MindElixirInstance
|
||||
E: (id: string, el?: HTMLElement) => Topic
|
||||
new: typeof MindElixir.new
|
||||
version: string
|
||||
LEFT: typeof LEFT
|
||||
RIGHT: typeof RIGHT
|
||||
SIDE: typeof SIDE
|
||||
THEME: typeof THEME
|
||||
DARK_THEME: typeof DARK_THEME
|
||||
prototype: MindElixirMethods
|
||||
}
|
||||
|
||||
export default MindElixir as unknown as MindElixirCtor
|
||||
|
||||
// types
|
||||
export type * from './utils/pubsub'
|
||||
export type * from './types/index'
|
||||
export type * from './types/dom'
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
import type { Locale } from './i18n'
|
||||
import { rmSubline } from './nodeOperation'
|
||||
import type { Topic, Wrapper } from './types/dom'
|
||||
import type { MindElixirData, MindElixirInstance, NodeObj } from './types/index'
|
||||
import { fillParent, getTranslate, setExpand } from './utils/index'
|
||||
|
||||
function collectData(instance: MindElixirInstance) {
|
||||
return {
|
||||
nodeData: instance.isFocusMode ? instance.nodeDataBackup : instance.nodeData,
|
||||
arrows: instance.arrows,
|
||||
summaries: instance.summaries,
|
||||
direction: instance.direction,
|
||||
theme: instance.theme,
|
||||
}
|
||||
}
|
||||
|
||||
export const scrollIntoView = function (this: MindElixirInstance, el: HTMLElement) {
|
||||
// scrollIntoView needs to be implemented manually because native scrollIntoView behaves incorrectly after transform
|
||||
const container = this.container
|
||||
const rect = el.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const isOutOfView =
|
||||
rect.top > containerRect.bottom || rect.bottom < containerRect.top || rect.left > containerRect.right || rect.right < containerRect.left
|
||||
if (isOutOfView) {
|
||||
// Calculate the offset between container center and element center
|
||||
const elCenterX = rect.left + rect.width / 2
|
||||
const elCenterY = rect.top + rect.height / 2
|
||||
const containerCenterX = containerRect.left + containerRect.width / 2
|
||||
const containerCenterY = containerRect.top + containerRect.height / 2
|
||||
const offsetX = elCenterX - containerCenterX
|
||||
const offsetY = elCenterY - containerCenterY
|
||||
this.move(-offsetX, -offsetY, true)
|
||||
}
|
||||
}
|
||||
|
||||
export const selectNode = function (this: MindElixirInstance, tpc: Topic, isNewNode?: boolean, e?: MouseEvent): void {
|
||||
// selectNode clears all selected nodes by default
|
||||
this.clearSelection()
|
||||
this.scrollIntoView(tpc)
|
||||
this.selection.select(tpc)
|
||||
if (isNewNode) {
|
||||
this.bus.fire('selectNewNode', tpc.nodeObj)
|
||||
}
|
||||
}
|
||||
|
||||
export const selectNodes = function (this: MindElixirInstance, tpc: Topic[]): void {
|
||||
// update currentNodes in selection.ts to keep sync with SelectionArea cache
|
||||
this.selection.select(tpc)
|
||||
}
|
||||
|
||||
export const unselectNodes = function (this: MindElixirInstance, tpc: Topic[]) {
|
||||
this.selection.deselect(tpc)
|
||||
}
|
||||
|
||||
export const clearSelection = function (this: MindElixirInstance) {
|
||||
this.unselectNodes(this.currentNodes)
|
||||
this.unselectSummary()
|
||||
this.unselectArrow()
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name getDataString
|
||||
* @description Get all node data as string.
|
||||
* @memberof MapInteraction
|
||||
* @return {string}
|
||||
*/
|
||||
export const getDataString = function (this: MindElixirInstance) {
|
||||
const data = collectData(this)
|
||||
return JSON.stringify(data, (k, v) => {
|
||||
if (k === 'parent' && typeof v !== 'string') return undefined
|
||||
return v
|
||||
})
|
||||
}
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name getData
|
||||
* @description Get all node data as object.
|
||||
* @memberof MapInteraction
|
||||
* @return {Object}
|
||||
*/
|
||||
export const getData = function (this: MindElixirInstance) {
|
||||
return JSON.parse(this.getDataString()) as MindElixirData
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name enableEdit
|
||||
* @memberof MapInteraction
|
||||
*/
|
||||
export const enableEdit = function (this: MindElixirInstance) {
|
||||
this.editable = true
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name disableEdit
|
||||
* @memberof MapInteraction
|
||||
*/
|
||||
export const disableEdit = function (this: MindElixirInstance) {
|
||||
this.editable = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name scale
|
||||
* @description Change the scale of the mind map.
|
||||
* @memberof MapInteraction
|
||||
* @param {number}
|
||||
*/
|
||||
export const scale = function (this: MindElixirInstance, scaleVal: number, offset: { x: number; y: number } = { x: 0, y: 0 }) {
|
||||
if (scaleVal < this.scaleMin || scaleVal > this.scaleMax) return
|
||||
const rect = this.container.getBoundingClientRect()
|
||||
// refer to /refs/scale-calc.excalidraw for the process
|
||||
// remove coordinate system influence and calculate quantities directly
|
||||
// cursor xy
|
||||
const xc = offset.x ? offset.x - rect.left - rect.width / 2 : 0
|
||||
const yc = offset.y ? offset.y - rect.top - rect.height / 2 : 0
|
||||
|
||||
const { dx, dy } = getCenterDefault(this)
|
||||
const oldTransform = this.map.style.transform
|
||||
const { x: xCurrent, y: yCurrent } = getTranslate(oldTransform) // current offset
|
||||
// before xy
|
||||
const xb = xCurrent - dx
|
||||
const yb = yCurrent - dy
|
||||
|
||||
const oldScale = this.scaleVal
|
||||
// Note: cursor needs to be reversed, probably because transform itself is reversed
|
||||
const xres = (-xc + xb) * (1 - scaleVal / oldScale)
|
||||
const yres = (-yc + yb) * (1 - scaleVal / oldScale)
|
||||
|
||||
this.map.style.transform = `translate(${xCurrent - xres}px, ${yCurrent - yres}px) scale(${scaleVal})`
|
||||
this.scaleVal = scaleVal
|
||||
this.bus.fire('scale', scaleVal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Better to use with option `alignment: 'nodes'`.
|
||||
*/
|
||||
export const scaleFit = function (this: MindElixirInstance) {
|
||||
const heightPercent = this.nodes.offsetHeight / this.container.offsetHeight
|
||||
const widthPercent = this.nodes.offsetWidth / this.container.offsetWidth
|
||||
const scale = 1 / Math.max(1, Math.max(heightPercent, widthPercent))
|
||||
this.scaleVal = scale
|
||||
this.map.style.transform = 'scale(' + scale + ')'
|
||||
this.bus.fire('scale', scale)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the map by `dx` and `dy`.
|
||||
*/
|
||||
export const move = function (this: MindElixirInstance, dx: number, dy: number, smooth = false) {
|
||||
const { map, scaleVal, bus } = this
|
||||
const transform = map.style.transform
|
||||
let { x, y } = getTranslate(transform)
|
||||
x += dx
|
||||
y += dy
|
||||
|
||||
if (smooth) {
|
||||
map.style.transition = 'transform 0.3s'
|
||||
setTimeout(() => {
|
||||
map.style.transition = 'none'
|
||||
}, 300)
|
||||
}
|
||||
map.style.transform = `translate(${x}px, ${y}px) scale(${scaleVal})`
|
||||
|
||||
bus.fire('move', { dx, dy })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认居中的偏移
|
||||
*/
|
||||
const getCenterDefault = (mei: MindElixirInstance) => {
|
||||
const { container, map, nodes } = mei
|
||||
|
||||
const root = map.querySelector('me-root') as HTMLElement
|
||||
const pT = root.offsetTop
|
||||
const pL = root.offsetLeft
|
||||
const pW = root.offsetWidth
|
||||
const pH = root.offsetHeight
|
||||
|
||||
let dx, dy
|
||||
if (mei.alignment === 'root') {
|
||||
dx = container.offsetWidth / 2 - pL - pW / 2
|
||||
dy = container.offsetHeight / 2 - pT - pH / 2
|
||||
map.style.transformOrigin = `${pL + pW / 2}px 50%`
|
||||
} else {
|
||||
dx = (container.offsetWidth - nodes.offsetWidth) / 2
|
||||
dy = (container.offsetHeight - nodes.offsetHeight) / 2
|
||||
map.style.transformOrigin = `50% 50%`
|
||||
}
|
||||
return { dx, dy }
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name toCenter
|
||||
* @description Reset position of the map to center.
|
||||
* @memberof MapInteraction
|
||||
*/
|
||||
export const toCenter = function (this: MindElixirInstance) {
|
||||
const { map } = this
|
||||
const { dx, dy } = getCenterDefault(this)
|
||||
map.style.transform = `translate(${dx}px, ${dy}px) scale(${this.scaleVal})`
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name install
|
||||
* @description Install plugin.
|
||||
* @memberof MapInteraction
|
||||
*/
|
||||
export const install = function (this: MindElixirInstance, plugin: (instance: MindElixirInstance) => void) {
|
||||
plugin(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name focusNode
|
||||
* @description Enter focus mode, set the target element as root.
|
||||
* @memberof MapInteraction
|
||||
* @param {TargetElement} el - Target element return by E('...'), default value: currentTarget.
|
||||
*/
|
||||
export const focusNode = function (this: MindElixirInstance, el: Topic) {
|
||||
if (!el.nodeObj.parent) return
|
||||
this.clearSelection()
|
||||
if (this.tempDirection === null) {
|
||||
this.tempDirection = this.direction
|
||||
}
|
||||
if (!this.isFocusMode) {
|
||||
this.nodeDataBackup = this.nodeData // help reset focus mode
|
||||
this.isFocusMode = true
|
||||
}
|
||||
this.nodeData = el.nodeObj
|
||||
this.initRight()
|
||||
this.toCenter()
|
||||
}
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name cancelFocus
|
||||
* @description Exit focus mode.
|
||||
* @memberof MapInteraction
|
||||
*/
|
||||
export const cancelFocus = function (this: MindElixirInstance) {
|
||||
this.isFocusMode = false
|
||||
if (this.tempDirection !== null) {
|
||||
this.nodeData = this.nodeDataBackup
|
||||
this.direction = this.tempDirection
|
||||
this.tempDirection = null
|
||||
this.refresh()
|
||||
this.toCenter()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name initLeft
|
||||
* @description Child nodes will distribute on the left side of the root node.
|
||||
* @memberof MapInteraction
|
||||
*/
|
||||
export const initLeft = function (this: MindElixirInstance) {
|
||||
this.direction = 0
|
||||
this.refresh()
|
||||
this.toCenter()
|
||||
}
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name initRight
|
||||
* @description Child nodes will distribute on the right side of the root node.
|
||||
* @memberof MapInteraction
|
||||
*/
|
||||
export const initRight = function (this: MindElixirInstance) {
|
||||
this.direction = 1
|
||||
this.refresh()
|
||||
this.toCenter()
|
||||
}
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name initSide
|
||||
* @description Child nodes will distribute on both left and right side of the root node.
|
||||
* @memberof MapInteraction
|
||||
*/
|
||||
export const initSide = function (this: MindElixirInstance) {
|
||||
this.direction = 2
|
||||
this.refresh()
|
||||
this.toCenter()
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name setLocale
|
||||
* @memberof MapInteraction
|
||||
*/
|
||||
export const setLocale = function (this: MindElixirInstance, locale: Locale) {
|
||||
this.locale = locale
|
||||
this.refresh()
|
||||
}
|
||||
|
||||
export const expandNode = function (this: MindElixirInstance, el: Topic, isExpand?: boolean) {
|
||||
const node = el.nodeObj
|
||||
if (typeof isExpand === 'boolean') {
|
||||
node.expanded = isExpand
|
||||
} else if (node.expanded !== false) {
|
||||
node.expanded = false
|
||||
} else {
|
||||
node.expanded = true
|
||||
}
|
||||
|
||||
// Calculate position before expansion
|
||||
const expanderRect = el.getBoundingClientRect()
|
||||
const beforePosition = {
|
||||
x: expanderRect.left,
|
||||
y: expanderRect.top,
|
||||
}
|
||||
|
||||
const parent = el.parentNode
|
||||
const expander = parent.children[1]!
|
||||
expander.expanded = node.expanded
|
||||
expander.className = node.expanded ? 'minus' : ''
|
||||
|
||||
rmSubline(el)
|
||||
if (node.expanded) {
|
||||
const children = this.createChildren(
|
||||
node.children!.map(child => {
|
||||
const wrapper = this.createWrapper(child)
|
||||
return wrapper.grp
|
||||
})
|
||||
)
|
||||
parent.parentNode.appendChild(children)
|
||||
} else {
|
||||
const children = parent.parentNode.children[1]
|
||||
children.remove()
|
||||
}
|
||||
|
||||
this.linkDiv(el.closest('me-main > me-wrapper') as Wrapper)
|
||||
|
||||
// Calculate position after expansion and compensate for drift
|
||||
const afterRect = el.getBoundingClientRect()
|
||||
const afterPosition = {
|
||||
x: afterRect.left,
|
||||
y: afterRect.top,
|
||||
}
|
||||
|
||||
// Calculate the drift and move to compensate
|
||||
const driftX = beforePosition.x - afterPosition.x
|
||||
const driftY = beforePosition.y - afterPosition.y
|
||||
|
||||
this.move(driftX, driftY)
|
||||
|
||||
this.bus.fire('expandNode', node)
|
||||
}
|
||||
|
||||
export const expandNodeAll = function (this: MindElixirInstance, el: Topic, isExpand?: boolean) {
|
||||
const node = el.nodeObj
|
||||
const beforeRect = el.getBoundingClientRect()
|
||||
const beforePosition = {
|
||||
x: beforeRect.left,
|
||||
y: beforeRect.top,
|
||||
}
|
||||
setExpand(node, isExpand ?? !node.expanded)
|
||||
this.refresh()
|
||||
const afterRect = this.findEle(node.id).getBoundingClientRect()
|
||||
const afterPosition = {
|
||||
x: afterRect.left,
|
||||
y: afterRect.top,
|
||||
}
|
||||
const driftX = beforePosition.x - afterPosition.x
|
||||
const driftY = beforePosition.y - afterPosition.y
|
||||
|
||||
this.move(driftX, driftY)
|
||||
}
|
||||
|
||||
/**
|
||||
* @function
|
||||
* @instance
|
||||
* @name refresh
|
||||
* @description Refresh mind map, you can use it after modified `this.nodeData`
|
||||
* @memberof MapInteraction
|
||||
* @param {TargetElement} data mind elixir data
|
||||
*/
|
||||
export const refresh = function (this: MindElixirInstance, data?: MindElixirData) {
|
||||
this.clearSelection()
|
||||
if (data) {
|
||||
data = JSON.parse(JSON.stringify(data)) as MindElixirData // it shouldn't contanimate the original data
|
||||
this.nodeData = data.nodeData
|
||||
this.arrows = data.arrows || []
|
||||
this.summaries = data.summaries || []
|
||||
data.theme && this.changeTheme(data.theme)
|
||||
}
|
||||
fillParent(this.nodeData)
|
||||
// create dom element for every node
|
||||
this.layout()
|
||||
// generate links between nodes
|
||||
this.linkDiv()
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { createPath, createLinkSvg } from './utils/svg'
|
||||
import { getOffsetLT } from './utils/index'
|
||||
import type { Wrapper, Topic } from './types/dom'
|
||||
import type { DirectionClass, MindElixirInstance } from './types/index'
|
||||
|
||||
/**
|
||||
* Link nodes with svg,
|
||||
* only link specific node if `mainNode` is present
|
||||
*
|
||||
* procedure:
|
||||
* 1. generate main link
|
||||
* 2. generate links inside main node, if `mainNode` is presented, only generate the link of the specific main node
|
||||
* 3. generate custom link
|
||||
* 4. generate summary
|
||||
* @param mainNode regenerate sublink of the specific main node
|
||||
*/
|
||||
const linkDiv = function (this: MindElixirInstance, mainNode?: Wrapper) {
|
||||
console.time('linkDiv')
|
||||
|
||||
const root = this.map.querySelector('me-root') as HTMLElement
|
||||
const pT = root.offsetTop
|
||||
const pL = root.offsetLeft
|
||||
const pW = root.offsetWidth
|
||||
const pH = root.offsetHeight
|
||||
|
||||
const mainNodeList = this.map.querySelectorAll('me-main > me-wrapper')
|
||||
this.lines.innerHTML = ''
|
||||
|
||||
for (let i = 0; i < mainNodeList.length; i++) {
|
||||
const el = mainNodeList[i] as Wrapper
|
||||
const tpc = el.querySelector<Topic>('me-tpc') as Topic
|
||||
const { offsetLeft: cL, offsetTop: cT } = getOffsetLT(this.nodes, tpc)
|
||||
const cW = tpc.offsetWidth
|
||||
const cH = tpc.offsetHeight
|
||||
const direction = el.parentNode.className as DirectionClass
|
||||
|
||||
const mainPath = this.generateMainBranch({ pT, pL, pW, pH, cT, cL, cW, cH, direction, containerHeight: this.nodes.offsetHeight })
|
||||
const palette = this.theme.palette
|
||||
const branchColor = tpc.nodeObj.branchColor || palette[i % palette.length]
|
||||
tpc.style.borderColor = branchColor
|
||||
this.lines.appendChild(createPath(mainPath, branchColor, '3'))
|
||||
|
||||
// generate link inside main node
|
||||
if (mainNode && mainNode !== el) {
|
||||
continue
|
||||
}
|
||||
|
||||
const svg = createLinkSvg('subLines')
|
||||
// svg tag name is lower case
|
||||
const svgLine = el.lastChild as SVGSVGElement
|
||||
if (svgLine.tagName === 'svg') svgLine.remove()
|
||||
el.appendChild(svg)
|
||||
|
||||
traverseChildren(this, svg, branchColor, el, direction, true)
|
||||
}
|
||||
|
||||
this.renderArrow()
|
||||
this.renderSummary()
|
||||
console.timeEnd('linkDiv')
|
||||
this.bus.fire('linkDiv')
|
||||
}
|
||||
|
||||
// core function of generate subLines
|
||||
|
||||
const traverseChildren = function (
|
||||
mei: MindElixirInstance,
|
||||
svgContainer: SVGSVGElement,
|
||||
branchColor: string,
|
||||
wrapper: Wrapper,
|
||||
direction: DirectionClass,
|
||||
isFirst?: boolean
|
||||
) {
|
||||
const parent = wrapper.firstChild
|
||||
const children = wrapper.children[1].children
|
||||
if (children.length === 0) return
|
||||
|
||||
const pT = parent.offsetTop
|
||||
const pL = parent.offsetLeft
|
||||
const pW = parent.offsetWidth
|
||||
const pH = parent.offsetHeight
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i]
|
||||
const childP = child.firstChild
|
||||
const cT = childP.offsetTop
|
||||
const cL = childP.offsetLeft
|
||||
const cW = childP.offsetWidth
|
||||
const cH = childP.offsetHeight
|
||||
|
||||
const bc = childP.firstChild.nodeObj.branchColor || branchColor
|
||||
const path = mei.generateSubBranch({ pT, pL, pW, pH, cT, cL, cW, cH, direction, isFirst })
|
||||
svgContainer.appendChild(createPath(path, bc, '2'))
|
||||
|
||||
const expander = childP.children[1]
|
||||
|
||||
if (expander) {
|
||||
// this property is added in the layout phase
|
||||
if (!expander.expanded) continue
|
||||
} else {
|
||||
// expander not exist
|
||||
continue
|
||||
}
|
||||
|
||||
traverseChildren(mei, svgContainer, bc, child, direction)
|
||||
}
|
||||
}
|
||||
|
||||
export default linkDiv
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import type { MindElixirInstance, MindElixirData } from './index'
|
||||
import linkDiv from './linkDiv'
|
||||
import contextMenu from './plugin/contextMenu'
|
||||
import keypressInit from './plugin/keypress'
|
||||
import nodeDraggable from './plugin/nodeDraggable'
|
||||
import operationHistory from './plugin/operationHistory'
|
||||
import toolBar from './plugin/toolBar'
|
||||
import selection from './plugin/selection'
|
||||
import { editTopic, createWrapper, createParent, createChildren, createTopic, findEle } from './utils/dom'
|
||||
import { getObjById, generateNewObj, fillParent } from './utils/index'
|
||||
import { layout } from './utils/layout'
|
||||
import { changeTheme } from './utils/theme'
|
||||
import * as interact from './interact'
|
||||
import * as nodeOperation from './nodeOperation'
|
||||
import * as arrow from './arrow'
|
||||
import * as summary from './summary'
|
||||
import * as exportImage from './plugin/exportImage'
|
||||
|
||||
export type OperationMap = typeof nodeOperation
|
||||
export type Operations = keyof OperationMap
|
||||
type NodeOperation = {
|
||||
[K in Operations]: ReturnType<typeof beforeHook<K>>
|
||||
}
|
||||
|
||||
function beforeHook<T extends Operations>(
|
||||
fn: OperationMap[T],
|
||||
fnName: T
|
||||
): (this: MindElixirInstance, ...args: Parameters<OperationMap[T]>) => Promise<void> {
|
||||
return async function (this: MindElixirInstance, ...args: Parameters<OperationMap[T]>) {
|
||||
const hook = this.before[fnName]
|
||||
if (hook) {
|
||||
const res = await hook.apply(this, args)
|
||||
if (!res) return
|
||||
}
|
||||
;(fn as any).apply(this, args)
|
||||
}
|
||||
}
|
||||
|
||||
const operations = Object.keys(nodeOperation) as Array<Operations>
|
||||
const nodeOperationHooked = {} as NodeOperation
|
||||
if (import.meta.env.MODE !== 'lite') {
|
||||
for (let i = 0; i < operations.length; i++) {
|
||||
const operation = operations[i]
|
||||
nodeOperationHooked[operation] = beforeHook(nodeOperation[operation], operation)
|
||||
}
|
||||
}
|
||||
|
||||
export type MindElixirMethods = typeof methods
|
||||
|
||||
/**
|
||||
* Methods that mind-elixir instance can use
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
const methods = {
|
||||
getObjById,
|
||||
generateNewObj,
|
||||
layout,
|
||||
linkDiv,
|
||||
editTopic,
|
||||
createWrapper,
|
||||
createParent,
|
||||
createChildren,
|
||||
createTopic,
|
||||
findEle,
|
||||
changeTheme,
|
||||
...interact,
|
||||
...(nodeOperationHooked as NodeOperation),
|
||||
...arrow,
|
||||
...summary,
|
||||
...exportImage,
|
||||
init(this: MindElixirInstance, data: MindElixirData) {
|
||||
data = JSON.parse(JSON.stringify(data))
|
||||
if (!data || !data.nodeData) return new Error('MindElixir: `data` is required')
|
||||
if (data.direction !== undefined) {
|
||||
this.direction = data.direction
|
||||
}
|
||||
this.changeTheme(data.theme || this.theme, false)
|
||||
this.nodeData = data.nodeData
|
||||
fillParent(this.nodeData)
|
||||
this.arrows = data.arrows || []
|
||||
this.summaries = data.summaries || []
|
||||
this.tidyArrow()
|
||||
// plugins
|
||||
this.toolBar && toolBar(this)
|
||||
if (import.meta.env.MODE !== 'lite') {
|
||||
this.keypress && keypressInit(this, this.keypress)
|
||||
|
||||
if (this.editable) {
|
||||
selection(this)
|
||||
}
|
||||
if (this.contextMenu) {
|
||||
this.disposable.push(contextMenu(this, this.contextMenu))
|
||||
}
|
||||
this.draggable && this.disposable.push(nodeDraggable(this))
|
||||
this.allowUndo && this.disposable.push(operationHistory(this))
|
||||
}
|
||||
this.layout()
|
||||
this.linkDiv()
|
||||
this.toCenter()
|
||||
},
|
||||
destroy(this: Partial<MindElixirInstance>) {
|
||||
this.disposable!.forEach(fn => fn())
|
||||
if (this.el) this.el.innerHTML = ''
|
||||
this.el = undefined
|
||||
this.nodeData = undefined
|
||||
this.arrows = undefined
|
||||
this.summaries = undefined
|
||||
this.currentArrow = undefined
|
||||
this.currentNodes = undefined
|
||||
this.currentSummary = undefined
|
||||
this.waitCopy = undefined
|
||||
this.theme = undefined
|
||||
this.direction = undefined
|
||||
this.bus = undefined
|
||||
this.container = undefined
|
||||
this.map = undefined
|
||||
this.lines = undefined
|
||||
this.linkController = undefined
|
||||
this.linkSvgGroup = undefined
|
||||
this.P2 = undefined
|
||||
this.P3 = undefined
|
||||
this.line1 = undefined
|
||||
this.line2 = undefined
|
||||
this.nodes = undefined
|
||||
this.selection?.destroy()
|
||||
this.selection = undefined
|
||||
},
|
||||
}
|
||||
|
||||
export default methods
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
import { handleZoom } from './plugin/keypress'
|
||||
import type { SummarySvgGroup } from './summary'
|
||||
import type { Expander, CustomSvg, Topic } from './types/dom'
|
||||
import type { MindElixirInstance } from './types/index'
|
||||
import { isTopic, on } from './utils'
|
||||
|
||||
export default function (mind: MindElixirInstance) {
|
||||
const { dragMoveHelper } = mind
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
console.log('handleClick', e)
|
||||
// Only handle primary button clicks
|
||||
if (e.button !== 0) return
|
||||
if (mind.helper1?.moved) {
|
||||
mind.helper1.clear()
|
||||
return
|
||||
}
|
||||
if (mind.helper2?.moved) {
|
||||
mind.helper2.clear()
|
||||
return
|
||||
}
|
||||
if (dragMoveHelper.moved) {
|
||||
dragMoveHelper.clear()
|
||||
return
|
||||
}
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'ME-EPD') {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
mind.expandNodeAll((target as Expander).previousSibling)
|
||||
} else {
|
||||
mind.expandNode((target as Expander).previousSibling)
|
||||
}
|
||||
} else if (target.tagName === 'ME-TPC' && mind.currentNodes.length > 1) {
|
||||
// This is a bit complex, intertwined with selection and nodeDraggable
|
||||
// The main conflict is between multi-node dragging and selecting a single node when multiple nodes are already selected
|
||||
mind.selectNode(target as Topic)
|
||||
} else if (!mind.editable) {
|
||||
return
|
||||
}
|
||||
const trySvg = target.parentElement?.parentElement as unknown as SVGElement
|
||||
if (trySvg.getAttribute('class') === 'topiclinks') {
|
||||
mind.selectArrow(target.parentElement as unknown as CustomSvg)
|
||||
} else if (trySvg.getAttribute('class') === 'summary') {
|
||||
mind.selectSummary(target.parentElement as unknown as SummarySvgGroup)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDblClick = (e: MouseEvent) => {
|
||||
if (!mind.editable) return
|
||||
const target = e.target as HTMLElement
|
||||
if (isTopic(target)) {
|
||||
mind.beginEdit(target)
|
||||
}
|
||||
const trySvg = target.parentElement?.parentElement as unknown as SVGElement
|
||||
if (trySvg.getAttribute('class') === 'topiclinks') {
|
||||
mind.editArrowLabel(target.parentElement as unknown as CustomSvg)
|
||||
} else if (trySvg.getAttribute('class') === 'summary') {
|
||||
mind.editSummary(target.parentElement as unknown as SummarySvgGroup)
|
||||
}
|
||||
}
|
||||
|
||||
let lastTap = 0
|
||||
const handleTouchDblClick = (e: PointerEvent) => {
|
||||
if (e.pointerType === 'mouse') return
|
||||
const currentTime = new Date().getTime()
|
||||
const tapLength = currentTime - lastTap
|
||||
console.log('tapLength', tapLength)
|
||||
if (tapLength < 300 && tapLength > 0) {
|
||||
handleDblClick(e)
|
||||
}
|
||||
|
||||
lastTap = currentTime
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
dragMoveHelper.moved = false
|
||||
const mouseMoveButton = mind.mouseSelectionButton === 0 ? 2 : 0
|
||||
if (e.button !== mouseMoveButton && e.pointerType === 'mouse') return
|
||||
|
||||
// Store initial position for movement calculation
|
||||
dragMoveHelper.x = e.clientX
|
||||
dragMoveHelper.y = e.clientY
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
if (target.className === 'circle') return
|
||||
if (target.contentEditable !== 'plaintext-only') {
|
||||
dragMoveHelper.mousedown = true
|
||||
// Capture pointer to ensure we receive all pointer events even if pointer moves outside the element
|
||||
target.setPointerCapture(e.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
// click trigger pointermove in windows chrome
|
||||
if ((e.target as HTMLElement).contentEditable !== 'plaintext-only') {
|
||||
// drag and move the map
|
||||
// Calculate movement delta manually since pointer events don't have movementX/Y
|
||||
const movementX = e.clientX - dragMoveHelper.x
|
||||
const movementY = e.clientY - dragMoveHelper.y
|
||||
|
||||
dragMoveHelper.onMove(movementX, movementY)
|
||||
}
|
||||
|
||||
dragMoveHelper.x = e.clientX
|
||||
dragMoveHelper.y = e.clientY
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: PointerEvent) => {
|
||||
const mouseMoveButton = mind.mouseSelectionButton === 0 ? 2 : 0
|
||||
if (e.button !== mouseMoveButton && e.pointerType === 'mouse') return
|
||||
const target = e.target as HTMLElement
|
||||
// Release pointer capture
|
||||
if (target.hasPointerCapture && target.hasPointerCapture(e.pointerId)) {
|
||||
target.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
dragMoveHelper.clear()
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
console.log('handleContextMenu', e)
|
||||
e.preventDefault()
|
||||
// Only handle right-click for context menu
|
||||
if (e.button !== 2) return
|
||||
if (!mind.editable) return
|
||||
const target = e.target as HTMLElement
|
||||
if (isTopic(target) && !target.classList.contains('selected')) {
|
||||
mind.selectNode(target)
|
||||
}
|
||||
setTimeout(() => {
|
||||
// delay to avoid conflict with click event on Mac
|
||||
if (mind.dragMoveHelper.moved) return
|
||||
mind.bus.fire('showContextMenu', e)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.deltaY < 0) handleZoom(mind, 'in', mind.dragMoveHelper)
|
||||
else if (mind.scaleVal - mind.scaleSensitivity > 0) handleZoom(mind, 'out', mind.dragMoveHelper)
|
||||
} else if (e.shiftKey) {
|
||||
mind.move(-e.deltaY, 0)
|
||||
} else {
|
||||
mind.move(-e.deltaX, -e.deltaY)
|
||||
}
|
||||
}
|
||||
|
||||
const { container } = mind
|
||||
const off = on([
|
||||
{ dom: container, evt: 'pointerdown', func: handlePointerDown },
|
||||
{ dom: container, evt: 'pointermove', func: handlePointerMove },
|
||||
{ dom: container, evt: 'pointerup', func: handlePointerUp },
|
||||
{ dom: container, evt: 'pointerup', func: handleTouchDblClick },
|
||||
{ dom: container, evt: 'click', func: handleClick },
|
||||
{ dom: container, evt: 'dblclick', func: handleDblClick },
|
||||
{ dom: container, evt: 'contextmenu', func: handleContextMenu },
|
||||
{ dom: container, evt: 'wheel', func: typeof mind.handleWheel === 'function' ? mind.handleWheel : handleWheel },
|
||||
])
|
||||
return off
|
||||
}
|
||||
|
|
@ -1,314 +0,0 @@
|
|||
import { fillParent, refreshIds, unionTopics } from './utils/index'
|
||||
import { createExpander, shapeTpc } from './utils/dom'
|
||||
import { deepClone } from './utils/index'
|
||||
import type { Children, Topic } from './types/dom'
|
||||
import { DirectionClass, type MindElixirInstance, type NodeObj } from './types/index'
|
||||
import { insertNodeObj, insertParentNodeObj, moveUpObj, moveDownObj, removeNodeObj, moveNodeObj } from './utils/objectManipulation'
|
||||
import { addChildDom, removeNodeDom } from './utils/domManipulation'
|
||||
import { LEFT, RIGHT } from './const'
|
||||
|
||||
const typeMap: Record<string, InsertPosition> = {
|
||||
before: 'beforebegin',
|
||||
after: 'afterend',
|
||||
}
|
||||
|
||||
export const rmSubline = function (tpc: Topic) {
|
||||
const mainNode = tpc.parentElement.parentElement
|
||||
const lc = mainNode.lastElementChild
|
||||
if (lc?.tagName === 'svg') lc?.remove() // clear svg group of main node
|
||||
}
|
||||
|
||||
export const reshapeNode = function (this: MindElixirInstance, tpc: Topic, patchData: Partial<NodeObj>) {
|
||||
const nodeObj = tpc.nodeObj
|
||||
const origin = deepClone(nodeObj)
|
||||
// merge styles
|
||||
if (origin.style && patchData.style) {
|
||||
patchData.style = Object.assign(origin.style, patchData.style)
|
||||
}
|
||||
const newObj = Object.assign(nodeObj, patchData)
|
||||
shapeTpc.call(this, tpc, newObj)
|
||||
this.linkDiv()
|
||||
this.bus.fire('operation', {
|
||||
name: 'reshapeNode',
|
||||
obj: newObj,
|
||||
origin,
|
||||
})
|
||||
}
|
||||
|
||||
const addChildFunc = function (mei: MindElixirInstance, tpc: Topic, node?: NodeObj) {
|
||||
if (!tpc) return null
|
||||
const nodeObj = tpc.nodeObj
|
||||
if (nodeObj.expanded === false) {
|
||||
mei.expandNode(tpc, true)
|
||||
// dom had resetted
|
||||
tpc = mei.findEle(nodeObj.id) as Topic
|
||||
}
|
||||
const newNodeObj = node || mei.generateNewObj()
|
||||
if (nodeObj.children) nodeObj.children.push(newNodeObj)
|
||||
else nodeObj.children = [newNodeObj]
|
||||
fillParent(mei.nodeData)
|
||||
|
||||
const { grp, top: newTop } = mei.createWrapper(newNodeObj)
|
||||
addChildDom(mei, tpc, grp)
|
||||
return { newTop, newNodeObj }
|
||||
}
|
||||
|
||||
export const insertSibling = function (this: MindElixirInstance, type: 'before' | 'after', el?: Topic, node?: NodeObj) {
|
||||
const nodeEle = el || this.currentNode
|
||||
if (!nodeEle) return
|
||||
const nodeObj = nodeEle.nodeObj
|
||||
if (!nodeObj.parent) {
|
||||
this.addChild()
|
||||
return
|
||||
} else if (!nodeObj.parent?.parent && nodeObj.parent?.children?.length === 1 && this.direction === 2) {
|
||||
// add at least one node to another side
|
||||
this.addChild(this.findEle(nodeObj.parent!.id), node)
|
||||
return
|
||||
}
|
||||
const newNodeObj = node || this.generateNewObj()
|
||||
if (!nodeObj.parent?.parent) {
|
||||
const direction = nodeEle.closest('me-main')!.className === DirectionClass.LHS ? LEFT : RIGHT
|
||||
newNodeObj.direction = direction
|
||||
}
|
||||
insertNodeObj(newNodeObj, type, nodeObj)
|
||||
fillParent(this.nodeData)
|
||||
const t = nodeEle.parentElement
|
||||
console.time('insertSibling_DOM')
|
||||
|
||||
const { grp, top } = this.createWrapper(newNodeObj)
|
||||
|
||||
t.parentElement.insertAdjacentElement(typeMap[type], grp)
|
||||
|
||||
this.linkDiv(grp.offsetParent)
|
||||
|
||||
if (!node) {
|
||||
this.editTopic(top.firstChild)
|
||||
}
|
||||
console.timeEnd('insertSibling_DOM')
|
||||
this.bus.fire('operation', {
|
||||
name: 'insertSibling',
|
||||
type,
|
||||
obj: newNodeObj,
|
||||
})
|
||||
this.selectNode(top.firstChild, true)
|
||||
}
|
||||
|
||||
export const insertParent = function (this: MindElixirInstance, el?: Topic, node?: NodeObj) {
|
||||
const nodeEle = el || this.currentNode
|
||||
if (!nodeEle) return
|
||||
rmSubline(nodeEle)
|
||||
const nodeObj = nodeEle.nodeObj
|
||||
if (!nodeObj.parent) {
|
||||
return
|
||||
}
|
||||
const newNodeObj = node || this.generateNewObj()
|
||||
insertParentNodeObj(nodeObj, newNodeObj)
|
||||
fillParent(this.nodeData)
|
||||
|
||||
const grp0 = nodeEle.parentElement.parentElement
|
||||
console.time('insertParent_DOM')
|
||||
const { grp, top } = this.createWrapper(newNodeObj, true)
|
||||
top.appendChild(createExpander(true))
|
||||
grp0.insertAdjacentElement('afterend', grp)
|
||||
|
||||
const c = this.createChildren([grp0])
|
||||
top.insertAdjacentElement('afterend', c)
|
||||
|
||||
this.linkDiv()
|
||||
|
||||
if (!node) {
|
||||
this.editTopic(top.firstChild)
|
||||
}
|
||||
this.selectNode(top.firstChild, true)
|
||||
console.timeEnd('insertParent_DOM')
|
||||
this.bus.fire('operation', {
|
||||
name: 'insertParent',
|
||||
obj: newNodeObj,
|
||||
})
|
||||
}
|
||||
|
||||
export const addChild = function (this: MindElixirInstance, el?: Topic, node?: NodeObj) {
|
||||
console.time('addChild')
|
||||
const nodeEle = el || this.currentNode
|
||||
if (!nodeEle) return
|
||||
const res = addChildFunc(this, nodeEle, node)
|
||||
if (!res) return
|
||||
const { newTop, newNodeObj } = res
|
||||
// 添加节点关注添加节点前选择的节点,所以先触发事件再选择节点
|
||||
this.bus.fire('operation', {
|
||||
name: 'addChild',
|
||||
obj: newNodeObj,
|
||||
})
|
||||
console.timeEnd('addChild')
|
||||
if (!node) {
|
||||
this.editTopic(newTop.firstChild)
|
||||
}
|
||||
this.selectNode(newTop.firstChild, true)
|
||||
}
|
||||
|
||||
export const copyNode = function (this: MindElixirInstance, node: Topic, to: Topic) {
|
||||
console.time('copyNode')
|
||||
const deepCloneObj = deepClone(node.nodeObj)
|
||||
refreshIds(deepCloneObj)
|
||||
const res = addChildFunc(this, to, deepCloneObj)
|
||||
if (!res) return
|
||||
const { newNodeObj } = res
|
||||
console.timeEnd('copyNode')
|
||||
this.selectNode(this.findEle(newNodeObj.id))
|
||||
this.bus.fire('operation', {
|
||||
name: 'copyNode',
|
||||
obj: newNodeObj,
|
||||
})
|
||||
}
|
||||
|
||||
export const copyNodes = function (this: MindElixirInstance, tpcs: Topic[], to: Topic) {
|
||||
tpcs = unionTopics(tpcs)
|
||||
const objs = []
|
||||
for (let i = 0; i < tpcs.length; i++) {
|
||||
const node = tpcs[i]
|
||||
const deepCloneObj = deepClone(node.nodeObj)
|
||||
refreshIds(deepCloneObj)
|
||||
const res = addChildFunc(this, to, deepCloneObj)
|
||||
if (!res) return
|
||||
const { newNodeObj } = res
|
||||
objs.push(newNodeObj)
|
||||
}
|
||||
this.unselectNodes(this.currentNodes)
|
||||
this.selectNodes(objs.map(obj => this.findEle(obj.id)))
|
||||
this.bus.fire('operation', {
|
||||
name: 'copyNodes',
|
||||
objs,
|
||||
})
|
||||
}
|
||||
|
||||
export const moveUpNode = function (this: MindElixirInstance, el?: Topic) {
|
||||
const nodeEle = el || this.currentNode
|
||||
if (!nodeEle) return
|
||||
const obj = nodeEle.nodeObj
|
||||
moveUpObj(obj)
|
||||
const grp = nodeEle.parentNode.parentNode
|
||||
grp.parentNode.insertBefore(grp, grp.previousSibling)
|
||||
this.linkDiv()
|
||||
this.bus.fire('operation', {
|
||||
name: 'moveUpNode',
|
||||
obj,
|
||||
})
|
||||
}
|
||||
|
||||
export const moveDownNode = function (this: MindElixirInstance, el?: Topic) {
|
||||
const nodeEle = el || this.currentNode
|
||||
if (!nodeEle) return
|
||||
const obj = nodeEle.nodeObj
|
||||
moveDownObj(obj)
|
||||
const grp = nodeEle.parentNode.parentNode
|
||||
if (grp.nextSibling) {
|
||||
grp.nextSibling.insertAdjacentElement('afterend', grp)
|
||||
} else {
|
||||
grp.parentNode.prepend(grp)
|
||||
}
|
||||
this.linkDiv()
|
||||
this.bus.fire('operation', {
|
||||
name: 'moveDownNode',
|
||||
obj,
|
||||
})
|
||||
}
|
||||
|
||||
export const removeNodes = function (this: MindElixirInstance, tpcs: Topic[]) {
|
||||
if (tpcs.length === 0) return
|
||||
tpcs = unionTopics(tpcs)
|
||||
for (const tpc of tpcs) {
|
||||
const nodeObj = tpc.nodeObj
|
||||
const siblingLength = removeNodeObj(nodeObj)
|
||||
removeNodeDom(tpc, siblingLength)
|
||||
}
|
||||
const last = tpcs[tpcs.length - 1]
|
||||
this.selectNode(this.findEle(last.nodeObj.parent!.id))
|
||||
this.linkDiv()
|
||||
// 删除关注的是删除后选择的节点,所以先选择节点再触发 removeNodes 事件可以在事件中通过 currentNodes 获取之后选择的节点
|
||||
this.bus.fire('operation', {
|
||||
name: 'removeNodes',
|
||||
objs: tpcs.map(tpc => tpc.nodeObj),
|
||||
})
|
||||
}
|
||||
|
||||
export const moveNodeIn = function (this: MindElixirInstance, from: Topic[], to: Topic) {
|
||||
from = unionTopics(from)
|
||||
const toObj = to.nodeObj
|
||||
if (toObj.expanded === false) {
|
||||
// TODO
|
||||
this.expandNode(to, true)
|
||||
to = this.findEle(toObj.id) as Topic
|
||||
}
|
||||
console.time('moveNodeIn')
|
||||
for (const f of from) {
|
||||
const obj = f.nodeObj
|
||||
moveNodeObj('in', obj, toObj)
|
||||
fillParent(this.nodeData) // update parent property
|
||||
const fromTop = f.parentElement
|
||||
addChildDom(this, to, fromTop.parentElement)
|
||||
}
|
||||
this.linkDiv()
|
||||
this.bus.fire('operation', {
|
||||
name: 'moveNodeIn',
|
||||
objs: from.map(f => f.nodeObj),
|
||||
toObj,
|
||||
})
|
||||
console.timeEnd('moveNodeIn')
|
||||
}
|
||||
|
||||
const moveNode = (from: Topic[], type: 'before' | 'after', to: Topic, mei: MindElixirInstance) => {
|
||||
from = unionTopics(from)
|
||||
if (type === 'after') {
|
||||
from = from.reverse()
|
||||
}
|
||||
const toObj = to.nodeObj
|
||||
const c: Children[] = []
|
||||
for (const f of from) {
|
||||
const obj = f.nodeObj
|
||||
moveNodeObj(type, obj, toObj)
|
||||
fillParent(mei.nodeData)
|
||||
rmSubline(f)
|
||||
const fromWrp = f.parentElement.parentNode
|
||||
if (!c.includes(fromWrp.parentElement)) {
|
||||
c.push(fromWrp.parentElement)
|
||||
}
|
||||
const toWrp = to.parentElement.parentNode
|
||||
toWrp.insertAdjacentElement(typeMap[type], fromWrp)
|
||||
}
|
||||
// When nodes are moved away, the original parent node may become childless
|
||||
// In this case, we need to clean up the related DOM structure:
|
||||
// remove expander buttons and empty wrapper containers
|
||||
for (const item of c) {
|
||||
if (item.childElementCount === 0 && item.tagName !== 'ME-MAIN') {
|
||||
item.previousSibling.children[1]!.remove()
|
||||
item.remove()
|
||||
}
|
||||
}
|
||||
mei.linkDiv()
|
||||
mei.bus.fire('operation', {
|
||||
name: type === 'before' ? 'moveNodeBefore' : 'moveNodeAfter',
|
||||
objs: from.map(f => f.nodeObj),
|
||||
toObj,
|
||||
})
|
||||
}
|
||||
|
||||
export const moveNodeBefore = function (this: MindElixirInstance, from: Topic[], to: Topic) {
|
||||
moveNode(from, 'before', to, this)
|
||||
}
|
||||
|
||||
export const moveNodeAfter = function (this: MindElixirInstance, from: Topic[], to: Topic) {
|
||||
moveNode(from, 'after', to, this)
|
||||
}
|
||||
|
||||
export const beginEdit = function (this: MindElixirInstance, el?: Topic) {
|
||||
const nodeEle = el || this.currentNode
|
||||
if (!nodeEle) return
|
||||
if (nodeEle.nodeObj.dangerouslySetInnerHTML) return
|
||||
this.editTopic(nodeEle)
|
||||
}
|
||||
|
||||
export const setNodeTopic = function (this: MindElixirInstance, el: Topic, topic: string) {
|
||||
el.text.textContent = topic
|
||||
el.nodeObj.topic = topic
|
||||
this.linkDiv()
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
.map-container .context-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 99;
|
||||
.menu-list {
|
||||
position: fixed;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--panel-color);
|
||||
box-shadow: 0 12px 15px 0 rgba(0, 0, 0, 0.2);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
li {
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 6px 10px;
|
||||
background: var(--panel-bgcolor);
|
||||
border-bottom: 1px solid var(--panel-border-color);
|
||||
cursor: pointer;
|
||||
span {
|
||||
line-height: 20px;
|
||||
}
|
||||
a {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
&.disabled {
|
||||
display: none;
|
||||
}
|
||||
&:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
span:last-child {
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
.key {
|
||||
font-size: 10px;
|
||||
background-color: #f1f1f1;
|
||||
color: #333;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.map-container .tips {
|
||||
position: absolute;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: var(--panel-color);
|
||||
background: var(--panel-bgcolor);
|
||||
opacity: 0.8;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
import i18n from '../i18n'
|
||||
import type { Topic } from '../types/dom'
|
||||
import type { MindElixirInstance } from '../types/index'
|
||||
import { encodeHTML, isTopic } from '../utils/index'
|
||||
import './contextMenu.less'
|
||||
import type { ArrowOptions } from '../arrow'
|
||||
|
||||
export type ContextMenuOption = {
|
||||
focus?: boolean
|
||||
link?: boolean
|
||||
extend?: {
|
||||
name: string
|
||||
key?: string
|
||||
onclick: (e: MouseEvent) => void
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function (mind: MindElixirInstance, option: true | ContextMenuOption) {
|
||||
option =
|
||||
option === true
|
||||
? {
|
||||
focus: true,
|
||||
link: true,
|
||||
}
|
||||
: option
|
||||
const createTips = (words: string) => {
|
||||
const div = document.createElement('div')
|
||||
div.innerText = words
|
||||
div.className = 'tips'
|
||||
return div
|
||||
}
|
||||
const createLi = (id: string, name: string, keyname: string) => {
|
||||
const li = document.createElement('li')
|
||||
li.id = id
|
||||
li.innerHTML = `<span>${encodeHTML(name)}</span><span ${keyname ? 'class="key"' : ''}>${encodeHTML(keyname)}</span>`
|
||||
return li
|
||||
}
|
||||
const locale = i18n[mind.locale] ? mind.locale : 'en'
|
||||
const lang = i18n[locale]
|
||||
const add_child = createLi('cm-add_child', lang.addChild, 'Tab')
|
||||
const add_parent = createLi('cm-add_parent', lang.addParent, 'Ctrl + Enter')
|
||||
const add_sibling = createLi('cm-add_sibling', lang.addSibling, 'Enter')
|
||||
const remove_child = createLi('cm-remove_child', lang.removeNode, 'Delete')
|
||||
const focus = createLi('cm-fucus', lang.focus, '')
|
||||
const unfocus = createLi('cm-unfucus', lang.cancelFocus, '')
|
||||
const up = createLi('cm-up', lang.moveUp, 'PgUp')
|
||||
const down = createLi('cm-down', lang.moveDown, 'Pgdn')
|
||||
const link = createLi('cm-link', lang.link, '')
|
||||
const linkBidirectional = createLi('cm-link-bidirectional', lang.linkBidirectional, '')
|
||||
const summary = createLi('cm-summary', lang.summary, '')
|
||||
|
||||
const menuUl = document.createElement('ul')
|
||||
menuUl.className = 'menu-list'
|
||||
menuUl.appendChild(add_child)
|
||||
menuUl.appendChild(add_parent)
|
||||
menuUl.appendChild(add_sibling)
|
||||
menuUl.appendChild(remove_child)
|
||||
if (option.focus) {
|
||||
menuUl.appendChild(focus)
|
||||
menuUl.appendChild(unfocus)
|
||||
}
|
||||
menuUl.appendChild(up)
|
||||
menuUl.appendChild(down)
|
||||
menuUl.appendChild(summary)
|
||||
if (option.link) {
|
||||
menuUl.appendChild(link)
|
||||
menuUl.appendChild(linkBidirectional)
|
||||
}
|
||||
if (option && option.extend) {
|
||||
for (let i = 0; i < option.extend.length; i++) {
|
||||
const item = option.extend[i]
|
||||
const dom = createLi(item.name, item.name, item.key || '')
|
||||
menuUl.appendChild(dom)
|
||||
dom.onclick = e => {
|
||||
item.onclick(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
const menuContainer = document.createElement('div')
|
||||
menuContainer.className = 'context-menu'
|
||||
menuContainer.appendChild(menuUl)
|
||||
menuContainer.hidden = true
|
||||
|
||||
mind.container.append(menuContainer)
|
||||
let isRoot = true
|
||||
// Helper function to actually render and position context menu.
|
||||
const showMenu = (e: MouseEvent) => {
|
||||
console.log('showContextMenu', e)
|
||||
const target = e.target as HTMLElement
|
||||
if (isTopic(target)) {
|
||||
if (target.parentElement!.tagName === 'ME-ROOT') {
|
||||
isRoot = true
|
||||
} else {
|
||||
isRoot = false
|
||||
}
|
||||
if (isRoot) {
|
||||
focus.className = 'disabled'
|
||||
up.className = 'disabled'
|
||||
down.className = 'disabled'
|
||||
add_parent.className = 'disabled'
|
||||
add_sibling.className = 'disabled'
|
||||
remove_child.className = 'disabled'
|
||||
} else {
|
||||
focus.className = ''
|
||||
up.className = ''
|
||||
down.className = ''
|
||||
add_parent.className = ''
|
||||
add_sibling.className = ''
|
||||
remove_child.className = ''
|
||||
}
|
||||
menuContainer.hidden = false
|
||||
|
||||
menuUl.style.top = ''
|
||||
menuUl.style.bottom = ''
|
||||
menuUl.style.left = ''
|
||||
menuUl.style.right = ''
|
||||
const rect = menuUl.getBoundingClientRect()
|
||||
const height = menuUl.offsetHeight
|
||||
const width = menuUl.offsetWidth
|
||||
|
||||
const relativeY = e.clientY - rect.top
|
||||
const relativeX = e.clientX - rect.left
|
||||
|
||||
if (height + relativeY > window.innerHeight) {
|
||||
menuUl.style.top = ''
|
||||
menuUl.style.bottom = '0px'
|
||||
} else {
|
||||
menuUl.style.bottom = ''
|
||||
menuUl.style.top = relativeY + 15 + 'px'
|
||||
}
|
||||
|
||||
if (width + relativeX > window.innerWidth) {
|
||||
menuUl.style.left = ''
|
||||
menuUl.style.right = '0px'
|
||||
} else {
|
||||
menuUl.style.right = ''
|
||||
menuUl.style.left = relativeX + 10 + 'px'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mind.bus.addListener('showContextMenu', showMenu)
|
||||
|
||||
menuContainer.onclick = e => {
|
||||
if (e.target === menuContainer) menuContainer.hidden = true
|
||||
}
|
||||
|
||||
add_child.onclick = () => {
|
||||
mind.addChild()
|
||||
menuContainer.hidden = true
|
||||
}
|
||||
add_parent.onclick = () => {
|
||||
mind.insertParent()
|
||||
menuContainer.hidden = true
|
||||
}
|
||||
add_sibling.onclick = () => {
|
||||
if (isRoot) return
|
||||
mind.insertSibling('after')
|
||||
menuContainer.hidden = true
|
||||
}
|
||||
remove_child.onclick = () => {
|
||||
if (isRoot) return
|
||||
mind.removeNodes(mind.currentNodes || [])
|
||||
menuContainer.hidden = true
|
||||
}
|
||||
focus.onclick = () => {
|
||||
if (isRoot) return
|
||||
mind.focusNode(mind.currentNode as Topic)
|
||||
menuContainer.hidden = true
|
||||
}
|
||||
unfocus.onclick = () => {
|
||||
mind.cancelFocus()
|
||||
menuContainer.hidden = true
|
||||
}
|
||||
up.onclick = () => {
|
||||
if (isRoot) return
|
||||
mind.moveUpNode()
|
||||
menuContainer.hidden = true
|
||||
}
|
||||
down.onclick = () => {
|
||||
if (isRoot) return
|
||||
mind.moveDownNode()
|
||||
menuContainer.hidden = true
|
||||
}
|
||||
const linkFunc = (options?: ArrowOptions) => {
|
||||
menuContainer.hidden = true
|
||||
const from = mind.currentNode as Topic
|
||||
const tips = createTips(lang.clickTips)
|
||||
mind.container.appendChild(tips)
|
||||
mind.map.addEventListener(
|
||||
'click',
|
||||
e => {
|
||||
e.preventDefault()
|
||||
tips.remove()
|
||||
const target = e.target as Topic
|
||||
if (target.parentElement.tagName === 'ME-PARENT' || target.parentElement.tagName === 'ME-ROOT') {
|
||||
mind.createArrow(from, target, options)
|
||||
} else {
|
||||
console.log('link cancel')
|
||||
}
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
link.onclick = () => linkFunc()
|
||||
linkBidirectional.onclick = () => linkFunc({ bidirectional: true })
|
||||
summary.onclick = () => {
|
||||
menuContainer.hidden = true
|
||||
mind.createSummary()
|
||||
mind.unselectNodes(mind.currentNodes)
|
||||
}
|
||||
return () => {
|
||||
// maybe useful?
|
||||
add_child.onclick = null
|
||||
add_parent.onclick = null
|
||||
add_sibling.onclick = null
|
||||
remove_child.onclick = null
|
||||
focus.onclick = null
|
||||
unfocus.onclick = null
|
||||
up.onclick = null
|
||||
down.onclick = null
|
||||
link.onclick = null
|
||||
summary.onclick = null
|
||||
menuContainer.onclick = null
|
||||
mind.container.oncontextmenu = null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
import type { Topic } from '../types/dom'
|
||||
import type { MindElixirInstance } from '../types'
|
||||
import { setAttributes } from '../utils'
|
||||
import { getOffsetLT, isTopic } from '../utils'
|
||||
|
||||
const ns = 'http://www.w3.org/2000/svg'
|
||||
function createSvgDom(height: string, width: string) {
|
||||
const svg = document.createElementNS(ns, 'svg')
|
||||
setAttributes(svg, {
|
||||
version: '1.1',
|
||||
xmlns: ns,
|
||||
height,
|
||||
width,
|
||||
})
|
||||
return svg
|
||||
}
|
||||
|
||||
function lineHightToPadding(lineHeight: string, fontSize: string) {
|
||||
return (parseInt(lineHeight) - parseInt(fontSize)) / 2
|
||||
}
|
||||
|
||||
function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) {
|
||||
const g = document.createElementNS(ns, 'g')
|
||||
let content = ''
|
||||
if ((tpc as Topic).text) {
|
||||
content = (tpc as Topic).text.textContent!
|
||||
} else {
|
||||
content = tpc.childNodes[0].textContent!
|
||||
}
|
||||
const lines = content!.split('\n')
|
||||
lines.forEach((line, index) => {
|
||||
const text = document.createElementNS(ns, 'text')
|
||||
setAttributes(text, {
|
||||
x: x + parseInt(tpcStyle.paddingLeft) + '',
|
||||
y:
|
||||
y +
|
||||
parseInt(tpcStyle.paddingTop) +
|
||||
lineHightToPadding(tpcStyle.lineHeight, tpcStyle.fontSize) * (index + 1) +
|
||||
parseFloat(tpcStyle.fontSize) * (index + 1) +
|
||||
'',
|
||||
'text-anchor': 'start',
|
||||
'font-family': tpcStyle.fontFamily,
|
||||
'font-size': `${tpcStyle.fontSize}`,
|
||||
'font-weight': `${tpcStyle.fontWeight}`,
|
||||
fill: `${tpcStyle.color}`,
|
||||
})
|
||||
text.innerHTML = line
|
||||
g.appendChild(text)
|
||||
})
|
||||
return g
|
||||
}
|
||||
|
||||
function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) {
|
||||
let content = ''
|
||||
if ((tpc as Topic).nodeObj?.dangerouslySetInnerHTML) {
|
||||
content = (tpc as Topic).nodeObj.dangerouslySetInnerHTML!
|
||||
} else if ((tpc as Topic).text) {
|
||||
content = (tpc as Topic).text.textContent!
|
||||
} else {
|
||||
content = tpc.childNodes[0].textContent!
|
||||
}
|
||||
const foreignObject = document.createElementNS(ns, 'foreignObject')
|
||||
setAttributes(foreignObject, {
|
||||
x: x + parseInt(tpcStyle.paddingLeft) + '',
|
||||
y: y + parseInt(tpcStyle.paddingTop) + '',
|
||||
width: tpcStyle.width,
|
||||
height: tpcStyle.height,
|
||||
})
|
||||
const div = document.createElement('div')
|
||||
setAttributes(div, {
|
||||
xmlns: 'http://www.w3.org/1999/xhtml',
|
||||
style: `font-family: ${tpcStyle.fontFamily}; font-size: ${tpcStyle.fontSize}; font-weight: ${tpcStyle.fontWeight}; color: ${tpcStyle.color}; white-space: pre-wrap;`,
|
||||
})
|
||||
div.innerHTML = content
|
||||
foreignObject.appendChild(div)
|
||||
return foreignObject
|
||||
}
|
||||
|
||||
function createElBox(mei: MindElixirInstance, tpc: Topic) {
|
||||
const tpcStyle = getComputedStyle(tpc)
|
||||
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc)
|
||||
|
||||
const bg = document.createElementNS(ns, 'rect')
|
||||
setAttributes(bg, {
|
||||
x: x + '',
|
||||
y: y + '',
|
||||
rx: tpcStyle.borderRadius,
|
||||
ry: tpcStyle.borderRadius,
|
||||
width: tpcStyle.width,
|
||||
height: tpcStyle.height,
|
||||
fill: tpcStyle.backgroundColor,
|
||||
stroke: tpcStyle.borderColor,
|
||||
'stroke-width': tpcStyle.borderWidth,
|
||||
})
|
||||
return bg
|
||||
}
|
||||
function convertDivToSvg(mei: MindElixirInstance, tpc: HTMLElement, useForeignObject = false) {
|
||||
const tpcStyle = getComputedStyle(tpc)
|
||||
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc)
|
||||
|
||||
const bg = document.createElementNS(ns, 'rect')
|
||||
setAttributes(bg, {
|
||||
x: x + '',
|
||||
y: y + '',
|
||||
rx: tpcStyle.borderRadius,
|
||||
ry: tpcStyle.borderRadius,
|
||||
width: tpcStyle.width,
|
||||
height: tpcStyle.height,
|
||||
fill: tpcStyle.backgroundColor,
|
||||
stroke: tpcStyle.borderColor,
|
||||
'stroke-width': tpcStyle.borderWidth,
|
||||
})
|
||||
const g = document.createElementNS(ns, 'g')
|
||||
g.appendChild(bg)
|
||||
let text: SVGGElement | null
|
||||
if (useForeignObject) {
|
||||
text = generateSvgTextUsingForeignObject(tpc, tpcStyle, x, y)
|
||||
} else text = generateSvgText(tpc, tpcStyle, x, y)
|
||||
g.appendChild(text)
|
||||
return g
|
||||
}
|
||||
|
||||
function convertAToSvg(mei: MindElixirInstance, a: HTMLAnchorElement) {
|
||||
const aStyle = getComputedStyle(a)
|
||||
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, a)
|
||||
const svgA = document.createElementNS(ns, 'a')
|
||||
const text = document.createElementNS(ns, 'text')
|
||||
setAttributes(text, {
|
||||
x: x + '',
|
||||
y: y + parseInt(aStyle.fontSize) + '',
|
||||
'text-anchor': 'start',
|
||||
'font-family': aStyle.fontFamily,
|
||||
'font-size': `${aStyle.fontSize}`,
|
||||
'font-weight': `${aStyle.fontWeight}`,
|
||||
fill: `${aStyle.color}`,
|
||||
})
|
||||
text.innerHTML = a.textContent!
|
||||
svgA.appendChild(text)
|
||||
svgA.setAttribute('href', a.href)
|
||||
return svgA
|
||||
}
|
||||
|
||||
function convertImgToSvg(mei: MindElixirInstance, a: HTMLImageElement) {
|
||||
const aStyle = getComputedStyle(a)
|
||||
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, a)
|
||||
const svgI = document.createElementNS(ns, 'image')
|
||||
setAttributes(svgI, {
|
||||
x: x + '',
|
||||
y: y + '',
|
||||
width: aStyle.width + '',
|
||||
height: aStyle.height + '',
|
||||
href: a.src,
|
||||
})
|
||||
return svgI
|
||||
}
|
||||
|
||||
const padding = 100
|
||||
|
||||
const head = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`
|
||||
|
||||
const generateSvg = (mei: MindElixirInstance, noForeignObject = false) => {
|
||||
const mapDiv = mei.nodes
|
||||
const height = mapDiv.offsetHeight + padding * 2
|
||||
const width = mapDiv.offsetWidth + padding * 2
|
||||
const svg = createSvgDom(height + 'px', width + 'px')
|
||||
const g = document.createElementNS(ns, 'svg')
|
||||
const bgColor = document.createElementNS(ns, 'rect')
|
||||
setAttributes(bgColor, {
|
||||
x: '0',
|
||||
y: '0',
|
||||
width: `${width}`,
|
||||
height: `${height}`,
|
||||
fill: mei.theme.cssVar['--bgcolor'] as string,
|
||||
})
|
||||
svg.appendChild(bgColor)
|
||||
mapDiv.querySelectorAll('.subLines').forEach(item => {
|
||||
const clone = item.cloneNode(true) as SVGSVGElement
|
||||
const { offsetLeft, offsetTop } = getOffsetLT(mapDiv, item.parentElement as HTMLElement)
|
||||
clone.setAttribute('x', `${offsetLeft}`)
|
||||
clone.setAttribute('y', `${offsetTop}`)
|
||||
g.appendChild(clone)
|
||||
})
|
||||
|
||||
const mainLine = mapDiv.querySelector('.lines')?.cloneNode(true)
|
||||
mainLine && g.appendChild(mainLine)
|
||||
const topiclinks = mapDiv.querySelector('.topiclinks')?.cloneNode(true)
|
||||
topiclinks && g.appendChild(topiclinks)
|
||||
const summaries = mapDiv.querySelector('.summary')?.cloneNode(true)
|
||||
summaries && g.appendChild(summaries)
|
||||
|
||||
mapDiv.querySelectorAll<Topic>('me-tpc').forEach(tpc => {
|
||||
if (tpc.nodeObj.dangerouslySetInnerHTML) {
|
||||
g.appendChild(convertDivToSvg(mei, tpc, noForeignObject ? false : true))
|
||||
} else {
|
||||
g.appendChild(createElBox(mei, tpc))
|
||||
g.appendChild(convertDivToSvg(mei, tpc.text, noForeignObject ? false : true))
|
||||
}
|
||||
})
|
||||
mapDiv.querySelectorAll('.tags > span').forEach(tag => {
|
||||
g.appendChild(convertDivToSvg(mei, tag as HTMLElement))
|
||||
})
|
||||
mapDiv.querySelectorAll('.icons > span').forEach(icon => {
|
||||
g.appendChild(convertDivToSvg(mei, icon as HTMLElement))
|
||||
})
|
||||
mapDiv.querySelectorAll('.hyper-link').forEach(hl => {
|
||||
g.appendChild(convertAToSvg(mei, hl as HTMLAnchorElement))
|
||||
})
|
||||
mapDiv.querySelectorAll('img').forEach(img => {
|
||||
g.appendChild(convertImgToSvg(mei, img))
|
||||
})
|
||||
setAttributes(g, {
|
||||
x: padding + '',
|
||||
y: padding + '',
|
||||
overflow: 'visible',
|
||||
})
|
||||
svg.appendChild(g)
|
||||
return svg
|
||||
}
|
||||
|
||||
const generateSvgStr = (svgEl: SVGSVGElement, injectCss?: string) => {
|
||||
if (injectCss) svgEl.insertAdjacentHTML('afterbegin', '<style>' + injectCss + '</style>')
|
||||
return head + svgEl.outerHTML
|
||||
}
|
||||
|
||||
function blobToUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = evt => {
|
||||
resolve(evt.target!.result as string)
|
||||
}
|
||||
reader.onerror = err => {
|
||||
reject(err)
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export const exportSvg = function (this: MindElixirInstance, noForeignObject = false, injectCss?: string) {
|
||||
const svgEl = generateSvg(this, noForeignObject)
|
||||
const svgString = generateSvgStr(svgEl, injectCss)
|
||||
const blob = new Blob([svgString], { type: 'image/svg+xml' })
|
||||
return blob
|
||||
}
|
||||
|
||||
export const exportPng = async function (this: MindElixirInstance, noForeignObject = false, injectCss?: string): Promise<Blob | null> {
|
||||
const blob = this.exportSvg(noForeignObject, injectCss)
|
||||
// use base64 to bypass canvas taint
|
||||
const url = await blobToUrl(blob)
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.setAttribute('crossOrigin', 'anonymous')
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.width
|
||||
canvas.height = img.height
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, 0, 0)
|
||||
canvas.toBlob(resolve, 'image/png', 1)
|
||||
}
|
||||
img.src = url
|
||||
img.onerror = reject
|
||||
})
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
import type { Topic } from '../types/dom'
|
||||
import type { KeypressOptions, MindElixirInstance } from '../types/index'
|
||||
import { DirectionClass } from '../types/index'
|
||||
import { setExpand } from '../utils'
|
||||
|
||||
const selectRootLeft = (mei: MindElixirInstance) => {
|
||||
const tpcs = mei.map.querySelectorAll('.lhs>me-wrapper>me-parent>me-tpc')
|
||||
mei.selectNode(tpcs[Math.ceil(tpcs.length / 2) - 1] as Topic)
|
||||
}
|
||||
const selectRootRight = (mei: MindElixirInstance) => {
|
||||
const tpcs = mei.map.querySelectorAll('.rhs>me-wrapper>me-parent>me-tpc')
|
||||
mei.selectNode(tpcs[Math.ceil(tpcs.length / 2) - 1] as Topic)
|
||||
}
|
||||
const selectRoot = (mei: MindElixirInstance) => {
|
||||
mei.selectNode(mei.map.querySelector('me-root>me-tpc') as Topic)
|
||||
}
|
||||
const selectParent = function (mei: MindElixirInstance, currentNode: Topic) {
|
||||
const parent = currentNode.parentElement.parentElement.parentElement.previousSibling
|
||||
if (parent) {
|
||||
const target = parent.firstChild
|
||||
mei.selectNode(target)
|
||||
}
|
||||
}
|
||||
const selectFirstChild = function (mei: MindElixirInstance, currentNode: Topic) {
|
||||
const children = currentNode.parentElement.nextSibling
|
||||
if (children && children.firstChild) {
|
||||
const target = children.firstChild.firstChild.firstChild
|
||||
mei.selectNode(target)
|
||||
}
|
||||
}
|
||||
const handleLeftRight = function (mei: MindElixirInstance, direction: DirectionClass) {
|
||||
const current = mei.currentNode || mei.currentNodes?.[0]
|
||||
if (!current) return
|
||||
const nodeObj = current.nodeObj
|
||||
const main = current.offsetParent.offsetParent.parentElement
|
||||
if (!nodeObj.parent) {
|
||||
direction === DirectionClass.LHS ? selectRootLeft(mei) : selectRootRight(mei)
|
||||
} else if (main.className === direction) {
|
||||
selectFirstChild(mei, current)
|
||||
} else {
|
||||
if (!nodeObj.parent?.parent) {
|
||||
selectRoot(mei)
|
||||
} else {
|
||||
selectParent(mei, current)
|
||||
}
|
||||
}
|
||||
}
|
||||
const handlePrevNext = function (mei: MindElixirInstance, direction: 'previous' | 'next') {
|
||||
const current = mei.currentNode
|
||||
if (!current) return
|
||||
const nodeObj = current.nodeObj
|
||||
if (!nodeObj.parent) return
|
||||
const s = (direction + 'Sibling') as 'previousSibling' | 'nextSibling'
|
||||
const sibling = current.parentElement.parentElement[s]
|
||||
if (sibling) {
|
||||
mei.selectNode(sibling.firstChild.firstChild)
|
||||
} else {
|
||||
// handle multiple nodes including last node
|
||||
mei.selectNode(current)
|
||||
}
|
||||
}
|
||||
export const handleZoom = function (
|
||||
mei: MindElixirInstance,
|
||||
direction: 'in' | 'out',
|
||||
offset?: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
) {
|
||||
const { scaleVal, scaleSensitivity } = mei
|
||||
switch (direction) {
|
||||
case 'in':
|
||||
mei.scale(scaleVal + scaleSensitivity, offset)
|
||||
break
|
||||
case 'out':
|
||||
mei.scale(scaleVal - scaleSensitivity, offset)
|
||||
}
|
||||
}
|
||||
|
||||
export default function (mind: MindElixirInstance, options: boolean | KeypressOptions) {
|
||||
options = options === true ? {} : options
|
||||
const handleRemove = () => {
|
||||
if (mind.currentArrow) mind.removeArrow()
|
||||
else if (mind.currentSummary) mind.removeSummary(mind.currentSummary.summaryObj.id)
|
||||
else if (mind.currentNodes) {
|
||||
mind.removeNodes(mind.currentNodes)
|
||||
}
|
||||
}
|
||||
|
||||
// Track key sequence for Ctrl+K+Ctrl+0
|
||||
let ctrlKPressed = false
|
||||
let ctrlKTimeout: number | null = null
|
||||
const handleControlKPlusX = (e: KeyboardEvent) => {
|
||||
const nodeData = mind.nodeData
|
||||
if (e.key === '0') {
|
||||
// Ctrl+K+Ctrl+0: Collapse all nodes
|
||||
for (const node of nodeData.children!) {
|
||||
setExpand(node, false)
|
||||
}
|
||||
}
|
||||
if (e.key === '=') {
|
||||
// Ctrl+K+Ctrl+1: Expand all nodes
|
||||
for (const node of nodeData.children!) {
|
||||
setExpand(node, true)
|
||||
}
|
||||
}
|
||||
if (['1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(e.key)) {
|
||||
for (const node of nodeData.children!) {
|
||||
setExpand(node, true, Number(e.key) - 1)
|
||||
}
|
||||
}
|
||||
mind.refresh()
|
||||
mind.toCenter()
|
||||
|
||||
ctrlKPressed = false
|
||||
if (ctrlKTimeout) {
|
||||
clearTimeout(ctrlKTimeout)
|
||||
ctrlKTimeout = null
|
||||
mind.container.removeEventListener('keydown', handleControlKPlusX)
|
||||
}
|
||||
}
|
||||
const key2func: Record<string, (e: KeyboardEvent) => void> = {
|
||||
Enter: e => {
|
||||
if (e.shiftKey) {
|
||||
mind.insertSibling('before')
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
mind.insertParent()
|
||||
} else {
|
||||
mind.insertSibling('after')
|
||||
}
|
||||
},
|
||||
Tab: () => {
|
||||
mind.addChild()
|
||||
},
|
||||
F1: () => {
|
||||
mind.toCenter()
|
||||
},
|
||||
F2: () => {
|
||||
mind.beginEdit()
|
||||
},
|
||||
ArrowUp: e => {
|
||||
if (e.altKey) {
|
||||
mind.moveUpNode()
|
||||
} else if (e.metaKey || e.ctrlKey) {
|
||||
return mind.initSide()
|
||||
} else {
|
||||
handlePrevNext(mind, 'previous')
|
||||
}
|
||||
},
|
||||
ArrowDown: e => {
|
||||
if (e.altKey) {
|
||||
mind.moveDownNode()
|
||||
} else {
|
||||
handlePrevNext(mind, 'next')
|
||||
}
|
||||
},
|
||||
ArrowLeft: e => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
return mind.initLeft()
|
||||
}
|
||||
handleLeftRight(mind, DirectionClass.LHS)
|
||||
},
|
||||
ArrowRight: e => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
return mind.initRight()
|
||||
}
|
||||
handleLeftRight(mind, DirectionClass.RHS)
|
||||
},
|
||||
PageUp: () => {
|
||||
return mind.moveUpNode()
|
||||
},
|
||||
PageDown: () => {
|
||||
mind.moveDownNode()
|
||||
},
|
||||
c: (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
mind.waitCopy = mind.currentNodes
|
||||
}
|
||||
},
|
||||
x: (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
mind.waitCopy = mind.currentNodes
|
||||
handleRemove()
|
||||
}
|
||||
},
|
||||
v: (e: KeyboardEvent) => {
|
||||
if (!mind.waitCopy || !mind.currentNode) return
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (mind.waitCopy.length === 1) {
|
||||
mind.copyNode(mind.waitCopy[0], mind.currentNode)
|
||||
} else {
|
||||
mind.copyNodes(mind.waitCopy, mind.currentNode)
|
||||
}
|
||||
}
|
||||
},
|
||||
'=': (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
handleZoom(mind, 'in')
|
||||
}
|
||||
},
|
||||
'-': (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
handleZoom(mind, 'out')
|
||||
}
|
||||
},
|
||||
'0': (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
if (ctrlKPressed) {
|
||||
return
|
||||
} else {
|
||||
// Regular Ctrl+0: Reset zoom
|
||||
mind.scale(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
k: (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
ctrlKPressed = true
|
||||
// Reset the flag after 2 seconds if no follow-up key is pressed
|
||||
if (ctrlKTimeout) {
|
||||
clearTimeout(ctrlKTimeout)
|
||||
mind.container.removeEventListener('keydown', handleControlKPlusX)
|
||||
}
|
||||
ctrlKTimeout = window.setTimeout(() => {
|
||||
ctrlKPressed = false
|
||||
ctrlKTimeout = null
|
||||
}, 2000)
|
||||
mind.container.addEventListener('keydown', handleControlKPlusX)
|
||||
}
|
||||
},
|
||||
Delete: handleRemove,
|
||||
Backspace: handleRemove,
|
||||
...options,
|
||||
}
|
||||
mind.container.onkeydown = e => {
|
||||
// it will prevent all input in children node, so we have to stop propagation in input element
|
||||
e.preventDefault()
|
||||
if (!mind.editable) return
|
||||
const keyHandler = key2func[e.key]
|
||||
keyHandler && keyHandler(e)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
import type { Topic } from '../types/dom'
|
||||
import type { MindElixirInstance } from '../types/index'
|
||||
import { on } from '../utils'
|
||||
// https://html.spec.whatwg.org/multipage/dnd.html#drag-and-drop-processing-model
|
||||
type InsertType = 'before' | 'after' | 'in' | null
|
||||
const $d = document
|
||||
const insertPreview = function (tpc: Topic, insertTpye: InsertType) {
|
||||
if (!insertTpye) {
|
||||
clearPreview(tpc)
|
||||
return tpc
|
||||
}
|
||||
let el = tpc.querySelector('.insert-preview')
|
||||
const className = `insert-preview ${insertTpye} show`
|
||||
if (!el) {
|
||||
el = $d.createElement('div')
|
||||
tpc.appendChild(el)
|
||||
}
|
||||
el.className = className
|
||||
return tpc
|
||||
}
|
||||
|
||||
const clearPreview = function (el: Element | null) {
|
||||
if (!el) return
|
||||
const query = el.querySelectorAll('.insert-preview')
|
||||
for (const queryElement of query || []) {
|
||||
queryElement.remove()
|
||||
}
|
||||
}
|
||||
|
||||
const canMove = function (el: Element, dragged: Topic[]) {
|
||||
for (const node of dragged) {
|
||||
const isContain = node.parentElement.parentElement.contains(el)
|
||||
const ok = el && el.tagName === 'ME-TPC' && el !== node && !isContain && (el as Topic).nodeObj.parent
|
||||
if (!ok) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const createGhost = function (mei: MindElixirInstance) {
|
||||
const ghost = document.createElement('div')
|
||||
ghost.className = 'mind-elixir-ghost'
|
||||
mei.container.appendChild(ghost)
|
||||
return ghost
|
||||
}
|
||||
|
||||
class EdgeMoveController {
|
||||
private mind: MindElixirInstance
|
||||
private isMoving = false
|
||||
private interval: NodeJS.Timeout | null = null
|
||||
private speed = 20
|
||||
constructor(mind: MindElixirInstance) {
|
||||
this.mind = mind
|
||||
}
|
||||
move(dx: number, dy: number) {
|
||||
if (this.isMoving) return
|
||||
this.isMoving = true
|
||||
this.interval = setInterval(() => {
|
||||
this.mind.move(dx * this.speed * this.mind.scaleVal, dy * this.speed * this.mind.scaleVal)
|
||||
}, 100)
|
||||
}
|
||||
stop() {
|
||||
this.isMoving = false
|
||||
clearInterval(this.interval!)
|
||||
}
|
||||
}
|
||||
|
||||
export default function (mind: MindElixirInstance) {
|
||||
let insertTpye: InsertType = null
|
||||
let meet: Topic | null = null
|
||||
const ghost = createGhost(mind)
|
||||
const edgeMoveController = new EdgeMoveController(mind)
|
||||
|
||||
const handleDragStart = (e: DragEvent) => {
|
||||
mind.selection.cancel()
|
||||
const target = e.target as Topic
|
||||
if (target?.tagName !== 'ME-TPC') {
|
||||
// it should be a topic element, return if not
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
let nodes = mind.currentNodes
|
||||
if (!nodes?.includes(target)) {
|
||||
mind.selectNode(target)
|
||||
nodes = mind.currentNodes
|
||||
}
|
||||
mind.dragged = nodes
|
||||
if (nodes.length > 1) ghost.innerHTML = nodes.length + ''
|
||||
else ghost.innerHTML = target.innerHTML
|
||||
|
||||
for (const node of nodes) {
|
||||
node.parentElement.parentElement.style.opacity = '0.5'
|
||||
}
|
||||
e.dataTransfer!.setDragImage(ghost, 0, 0)
|
||||
e.dataTransfer!.dropEffect = 'move'
|
||||
mind.dragMoveHelper.clear()
|
||||
}
|
||||
const handleDragEnd = (e: DragEvent) => {
|
||||
const { dragged } = mind
|
||||
if (!dragged) return
|
||||
edgeMoveController.stop()
|
||||
for (const node of dragged) {
|
||||
node.parentElement.parentElement.style.opacity = '1'
|
||||
}
|
||||
const target = e.target as Topic
|
||||
target.style.opacity = ''
|
||||
if (!meet) return
|
||||
clearPreview(meet)
|
||||
if (insertTpye === 'before') {
|
||||
mind.moveNodeBefore(dragged, meet)
|
||||
} else if (insertTpye === 'after') {
|
||||
mind.moveNodeAfter(dragged, meet)
|
||||
} else if (insertTpye === 'in') {
|
||||
mind.moveNodeIn(dragged, meet)
|
||||
}
|
||||
mind.dragged = null
|
||||
ghost.innerHTML = ''
|
||||
}
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
const threshold = 12 * mind.scaleVal
|
||||
const { dragged } = mind
|
||||
|
||||
if (!dragged) return
|
||||
|
||||
// border detection
|
||||
const rect = mind.container.getBoundingClientRect()
|
||||
if (e.clientX < rect.x + 50) {
|
||||
edgeMoveController.move(1, 0)
|
||||
} else if (e.clientX > rect.x + rect.width - 50) {
|
||||
edgeMoveController.move(-1, 0)
|
||||
} else if (e.clientY < rect.y + 50) {
|
||||
edgeMoveController.move(0, 1)
|
||||
} else if (e.clientY > rect.y + rect.height - 50) {
|
||||
edgeMoveController.move(0, -1)
|
||||
} else {
|
||||
edgeMoveController.stop()
|
||||
}
|
||||
|
||||
clearPreview(meet)
|
||||
// minus threshold infer that postion of the cursor is above topic
|
||||
const topMeet = $d.elementFromPoint(e.clientX, e.clientY - threshold) as Topic
|
||||
if (canMove(topMeet, dragged)) {
|
||||
meet = topMeet
|
||||
const rect = topMeet.getBoundingClientRect()
|
||||
const y = rect.y
|
||||
if (e.clientY > y + rect.height) {
|
||||
insertTpye = 'after'
|
||||
} else {
|
||||
insertTpye = 'in'
|
||||
}
|
||||
} else {
|
||||
const bottomMeet = $d.elementFromPoint(e.clientX, e.clientY + threshold) as Topic
|
||||
const rect = bottomMeet.getBoundingClientRect()
|
||||
if (canMove(bottomMeet, dragged)) {
|
||||
meet = bottomMeet
|
||||
const y = rect.y
|
||||
if (e.clientY < y) {
|
||||
insertTpye = 'before'
|
||||
} else {
|
||||
insertTpye = 'in'
|
||||
}
|
||||
} else {
|
||||
insertTpye = meet = null
|
||||
}
|
||||
}
|
||||
if (meet) insertPreview(meet, insertTpye)
|
||||
}
|
||||
const off = on([
|
||||
{ dom: mind.map, evt: 'dragstart', func: handleDragStart },
|
||||
{ dom: mind.map, evt: 'dragend', func: handleDragEnd },
|
||||
{ dom: mind.map, evt: 'dragover', func: handleDragOver },
|
||||
])
|
||||
return off
|
||||
}
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import type { MindElixirData, NodeObj, OperationType } from '../index'
|
||||
import { type MindElixirInstance } from '../index'
|
||||
import type { Operation } from '../utils/pubsub'
|
||||
|
||||
type History = {
|
||||
prev: MindElixirData
|
||||
next: MindElixirData
|
||||
currentSelected: string[]
|
||||
operation: OperationType
|
||||
currentTarget:
|
||||
| {
|
||||
type: 'summary' | 'arrow'
|
||||
value: string
|
||||
}
|
||||
| {
|
||||
type: 'nodes'
|
||||
value: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const calcCurentObject = function (operation: Operation): History['currentTarget'] {
|
||||
if (['createSummary', 'removeSummary', 'finishEditSummary'].includes(operation.name)) {
|
||||
return {
|
||||
type: 'summary',
|
||||
value: (operation as any).obj.id,
|
||||
}
|
||||
} else if (['createArrow', 'removeArrow', 'finishEditArrowLabel'].includes(operation.name)) {
|
||||
return {
|
||||
type: 'arrow',
|
||||
value: (operation as any).obj.id,
|
||||
}
|
||||
} else if (['removeNodes', 'copyNodes', 'moveNodeBefore', 'moveNodeAfter', 'moveNodeIn'].includes(operation.name)) {
|
||||
return {
|
||||
type: 'nodes',
|
||||
value: (operation as any).objs.map((obj: NodeObj) => obj.id),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'nodes',
|
||||
value: [(operation as any).obj.id],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function (mei: MindElixirInstance) {
|
||||
let history = [] as History[]
|
||||
let currentIndex = -1
|
||||
let current = mei.getData()
|
||||
let currentSelectedNodes: NodeObj[] = []
|
||||
mei.undo = function () {
|
||||
// 操作是删除时,undo 恢复内容,应选中操作的目标
|
||||
// 操作是新增时,undo 删除内容,应选中当前选中节点
|
||||
if (currentIndex > -1) {
|
||||
const h = history[currentIndex]
|
||||
current = h.prev
|
||||
mei.refresh(h.prev)
|
||||
try {
|
||||
if (h.currentTarget.type === 'nodes') {
|
||||
if (h.operation === 'removeNodes') {
|
||||
mei.selectNodes(h.currentTarget.value.map(id => this.findEle(id)))
|
||||
} else {
|
||||
mei.selectNodes(h.currentSelected.map(id => this.findEle(id)))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// undo add node cause node not found
|
||||
} finally {
|
||||
currentIndex--
|
||||
}
|
||||
}
|
||||
}
|
||||
mei.redo = function () {
|
||||
if (currentIndex < history.length - 1) {
|
||||
currentIndex++
|
||||
const h = history[currentIndex]
|
||||
current = h.next
|
||||
mei.refresh(h.next)
|
||||
try {
|
||||
if (h.currentTarget.type === 'nodes') {
|
||||
if (h.operation === 'removeNodes') {
|
||||
mei.selectNodes(h.currentSelected.map(id => this.findEle(id)))
|
||||
} else {
|
||||
mei.selectNodes(h.currentTarget.value.map(id => this.findEle(id)))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// redo delete node cause node not found
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleOperation = function (operation: Operation) {
|
||||
if (operation.name === 'beginEdit') return
|
||||
history = history.slice(0, currentIndex + 1)
|
||||
const next = mei.getData()
|
||||
const item = {
|
||||
prev: current,
|
||||
operation: operation.name,
|
||||
currentSelected: currentSelectedNodes.map(n => n.id),
|
||||
currentTarget: calcCurentObject(operation),
|
||||
next,
|
||||
}
|
||||
history.push(item)
|
||||
current = next
|
||||
currentIndex = history.length - 1
|
||||
console.log('operation', item.currentSelected, item.currentTarget.value)
|
||||
}
|
||||
const handleKeyDown = function (e: KeyboardEvent) {
|
||||
// console.log(`mei.map.addEventListener('keydown', handleKeyDown)`, e.key, history.length, currentIndex)
|
||||
if ((e.metaKey || e.ctrlKey) && ((e.shiftKey && e.key === 'Z') || e.key === 'y')) mei.redo()
|
||||
else if ((e.metaKey || e.ctrlKey) && e.key === 'z') mei.undo()
|
||||
}
|
||||
const handleSelectNodes = function (nodes: NodeObj[]) {
|
||||
console.log('selectNodes', nodes)
|
||||
currentSelectedNodes = mei.currentNodes.map(n => n.nodeObj)
|
||||
}
|
||||
mei.bus.addListener('operation', handleOperation)
|
||||
mei.bus.addListener('selectNodes', handleSelectNodes)
|
||||
mei.container.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
mei.bus.removeListener('operation', handleOperation)
|
||||
mei.bus.removeListener('selectNodes', handleSelectNodes)
|
||||
mei.container.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import type { Behaviour } from '@viselect/vanilla'
|
||||
import SelectionArea from '@viselect/vanilla'
|
||||
import type { MindElixirInstance, Topic } from '..'
|
||||
|
||||
// TODO: boundaries move missing
|
||||
export default function (mei: MindElixirInstance) {
|
||||
const triggers: Behaviour['triggers'] = mei.mouseSelectionButton === 2 ? [2] : [0]
|
||||
const selection = new SelectionArea({
|
||||
selectables: ['.map-container me-tpc'],
|
||||
boundaries: [mei.container],
|
||||
container: mei.selectionContainer,
|
||||
features: {
|
||||
// deselectOnBlur: true,
|
||||
touch: false,
|
||||
},
|
||||
behaviour: {
|
||||
triggers,
|
||||
// Scroll configuration.
|
||||
scrolling: {
|
||||
// On scrollable areas the number on px per frame is devided by this amount.
|
||||
// Default is 10 to provide a enjoyable scroll experience.
|
||||
speedDivider: 10,
|
||||
// Browsers handle mouse-wheel events differently, this number will be used as
|
||||
// numerator to calculate the mount of px while scrolling manually: manualScrollSpeed / scrollSpeedDivider.
|
||||
manualSpeed: 750,
|
||||
// This property defines the virtual inset margins from the borders of the container
|
||||
// component that, when crossed by the mouse/touch, trigger the scrolling. Useful for
|
||||
// fullscreen containers.
|
||||
startScrollMargins: { x: 10, y: 10 },
|
||||
},
|
||||
},
|
||||
})
|
||||
.on('beforestart', ({ event }) => {
|
||||
const target = event!.target as HTMLElement
|
||||
if (target.id === 'input-box') return false
|
||||
if (target.className === 'circle') return false
|
||||
if (mei.container.querySelector('.context-menu')?.contains(target)) {
|
||||
// prevent context menu click clear selection
|
||||
return false
|
||||
}
|
||||
if (!(event as MouseEvent).ctrlKey && !(event as MouseEvent).metaKey) {
|
||||
if (target.tagName === 'ME-TPC' && target.classList.contains('selected')) {
|
||||
// Normal click cannot deselect
|
||||
// Also, deselection CANNOT be triggered before dragging, otherwise we can't drag multiple targets!!
|
||||
return false
|
||||
}
|
||||
// trigger `move` event here
|
||||
mei.clearSelection()
|
||||
}
|
||||
// console.log('beforestart')
|
||||
const selectionAreaElement = selection.getSelectionArea()
|
||||
selectionAreaElement.style.background = '#4f90f22d'
|
||||
selectionAreaElement.style.border = '1px solid #4f90f2'
|
||||
if (selectionAreaElement.parentElement) {
|
||||
selectionAreaElement.parentElement.style.zIndex = '9999'
|
||||
}
|
||||
return true
|
||||
})
|
||||
// .on('beforedrag', ({ event }) => {})
|
||||
.on(
|
||||
'move',
|
||||
({
|
||||
store: {
|
||||
changed: { added, removed },
|
||||
},
|
||||
}) => {
|
||||
if (added.length > 0 || removed.length > 0) {
|
||||
// console.log('added ', added)
|
||||
// console.log('removed ', removed)
|
||||
}
|
||||
if (added.length > 0) {
|
||||
for (const el of added) {
|
||||
el.className = 'selected'
|
||||
}
|
||||
mei.currentNodes = [...mei.currentNodes, ...(added as Topic[])]
|
||||
mei.bus.fire(
|
||||
'selectNodes',
|
||||
(added as Topic[]).map(el => el.nodeObj)
|
||||
)
|
||||
}
|
||||
if (removed.length > 0) {
|
||||
for (const el of removed) {
|
||||
el.classList.remove('selected')
|
||||
}
|
||||
mei.currentNodes = mei.currentNodes!.filter(el => !removed?.includes(el))
|
||||
mei.bus.fire(
|
||||
'unselectNodes',
|
||||
(removed as Topic[]).map(el => el.nodeObj)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
mei.selection = selection
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
.mind-elixir-toolbar {
|
||||
position: absolute;
|
||||
color: var(--panel-color);
|
||||
background: var(--panel-bgcolor);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
|
||||
|
||||
svg {
|
||||
display: inline-block; // overwrite tailwindcss
|
||||
}
|
||||
span {
|
||||
&:active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mind-elixir-toolbar.rb {
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
|
||||
span + span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.mind-elixir-toolbar.lt {
|
||||
font-size: 20px;
|
||||
left: 20px;
|
||||
top: 20px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
span + span {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import type { MindElixirInstance } from '../types/index'
|
||||
import side from '../icons/side.svg?raw'
|
||||
import left from '../icons/left.svg?raw'
|
||||
import right from '../icons/right.svg?raw'
|
||||
import full from '../icons/full.svg?raw'
|
||||
import living from '../icons/living.svg?raw'
|
||||
import zoomin from '../icons/zoomin.svg?raw'
|
||||
import zoomout from '../icons/zoomout.svg?raw'
|
||||
|
||||
import './toolBar.less'
|
||||
|
||||
const map: Record<string, string> = {
|
||||
side,
|
||||
left,
|
||||
right,
|
||||
full,
|
||||
living,
|
||||
zoomin,
|
||||
zoomout,
|
||||
}
|
||||
const createButton = (id: string, name: string) => {
|
||||
const button = document.createElement('span')
|
||||
button.id = id
|
||||
button.innerHTML = map[name]
|
||||
return button
|
||||
}
|
||||
|
||||
function createToolBarRBContainer(mind: MindElixirInstance) {
|
||||
const toolBarRBContainer = document.createElement('div')
|
||||
const fc = createButton('fullscreen', 'full')
|
||||
const gc = createButton('toCenter', 'living')
|
||||
const zo = createButton('zoomout', 'zoomout')
|
||||
const zi = createButton('zoomin', 'zoomin')
|
||||
const percentage = document.createElement('span')
|
||||
percentage.innerText = '100%'
|
||||
toolBarRBContainer.appendChild(fc)
|
||||
toolBarRBContainer.appendChild(gc)
|
||||
toolBarRBContainer.appendChild(zo)
|
||||
toolBarRBContainer.appendChild(zi)
|
||||
// toolBarRBContainer.appendChild(percentage)
|
||||
toolBarRBContainer.className = 'mind-elixir-toolbar rb'
|
||||
fc.onclick = () => {
|
||||
if (document.fullscreenElement === mind.el) {
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
mind.el.requestFullscreen()
|
||||
}
|
||||
}
|
||||
gc.onclick = () => {
|
||||
mind.toCenter()
|
||||
}
|
||||
zo.onclick = () => {
|
||||
mind.scale(mind.scaleVal - mind.scaleSensitivity)
|
||||
}
|
||||
zi.onclick = () => {
|
||||
mind.scale(mind.scaleVal + mind.scaleSensitivity)
|
||||
}
|
||||
return toolBarRBContainer
|
||||
}
|
||||
function createToolBarLTContainer(mind: MindElixirInstance) {
|
||||
const toolBarLTContainer = document.createElement('div')
|
||||
const l = createButton('tbltl', 'left')
|
||||
const r = createButton('tbltr', 'right')
|
||||
const s = createButton('tblts', 'side')
|
||||
|
||||
toolBarLTContainer.appendChild(l)
|
||||
toolBarLTContainer.appendChild(r)
|
||||
toolBarLTContainer.appendChild(s)
|
||||
toolBarLTContainer.className = 'mind-elixir-toolbar lt'
|
||||
l.onclick = () => {
|
||||
mind.initLeft()
|
||||
}
|
||||
r.onclick = () => {
|
||||
mind.initRight()
|
||||
}
|
||||
s.onclick = () => {
|
||||
mind.initSide()
|
||||
}
|
||||
return toolBarLTContainer
|
||||
}
|
||||
|
||||
export default function (mind: MindElixirInstance) {
|
||||
mind.container.append(createToolBarRBContainer(mind))
|
||||
mind.container.append(createToolBarLTContainer(mind))
|
||||
}
|
||||
|
|
@ -1,241 +0,0 @@
|
|||
import type { MindElixirInstance, Topic } from '.'
|
||||
import { DirectionClass } from './types/index'
|
||||
import { generateUUID, getOffsetLT, setAttributes } from './utils'
|
||||
import { createSvgText, editSvgText, svgNS } from './utils/svg'
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface Summary {
|
||||
id: string
|
||||
label: string
|
||||
/**
|
||||
* parent node id of the summary
|
||||
*/
|
||||
parent: string
|
||||
/**
|
||||
* start index of the summary
|
||||
*/
|
||||
start: number
|
||||
/**
|
||||
* end index of the summary
|
||||
*/
|
||||
end: number
|
||||
}
|
||||
|
||||
export type SummarySvgGroup = SVGGElement & {
|
||||
children: [SVGPathElement, SVGTextElement]
|
||||
summaryObj: Summary
|
||||
}
|
||||
|
||||
const calcRange = function (nodes: Topic[]) {
|
||||
if (nodes.length === 0) throw new Error('No selected node.')
|
||||
if (nodes.length === 1) {
|
||||
const obj = nodes[0].nodeObj
|
||||
const parent = nodes[0].nodeObj.parent
|
||||
if (!parent) throw new Error('Can not select root node.')
|
||||
const i = parent.children!.findIndex(child => obj === child)
|
||||
return {
|
||||
parent: parent.id,
|
||||
start: i,
|
||||
end: i,
|
||||
}
|
||||
}
|
||||
let maxLen = 0
|
||||
const parentChains = nodes.map(item => {
|
||||
let node = item.nodeObj
|
||||
const parentChain = []
|
||||
while (node.parent) {
|
||||
const parent = node.parent
|
||||
const siblings = parent.children
|
||||
const index = siblings?.indexOf(node)
|
||||
node = parent
|
||||
parentChain.unshift({ node, index })
|
||||
}
|
||||
if (parentChain.length > maxLen) maxLen = parentChain.length
|
||||
return parentChain
|
||||
})
|
||||
let index = 0
|
||||
// find minimum common parent
|
||||
findMcp: for (; index < maxLen; index++) {
|
||||
const base = parentChains[0][index]?.node
|
||||
for (let i = 1; i < parentChains.length; i++) {
|
||||
const parentChain = parentChains[i]
|
||||
if (parentChain[index]?.node !== base) {
|
||||
break findMcp
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!index) throw new Error('Can not select root node.')
|
||||
const range = parentChains.map(chain => chain[index - 1].index).sort()
|
||||
const min = range[0] || 0
|
||||
const max = range[range.length - 1] || 0
|
||||
const parent = parentChains[0][index - 1].node
|
||||
if (!parent.parent) throw new Error('Please select nodes in the same main topic.')
|
||||
|
||||
return {
|
||||
parent: parent.id,
|
||||
start: min,
|
||||
end: max,
|
||||
}
|
||||
}
|
||||
|
||||
const creatGroup = function (id: string) {
|
||||
const group = document.createElementNS(svgNS, 'g') as SummarySvgGroup
|
||||
group.setAttribute('id', id)
|
||||
return group
|
||||
}
|
||||
|
||||
const createPath = function (d: string, color?: string) {
|
||||
const path = document.createElementNS(svgNS, 'path')
|
||||
setAttributes(path, {
|
||||
d,
|
||||
stroke: color || '#666',
|
||||
fill: 'none',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-width': '2',
|
||||
})
|
||||
return path
|
||||
}
|
||||
|
||||
const getWrapper = (tpc: Topic) => tpc.parentElement.parentElement
|
||||
|
||||
const getDirection = function (mei: MindElixirInstance, { parent, start }: Summary) {
|
||||
const parentEl = mei.findEle(parent)
|
||||
const parentObj = parentEl.nodeObj
|
||||
let side: DirectionClass
|
||||
if (parentObj.parent) {
|
||||
side = parentEl.closest('me-main')!.className as DirectionClass
|
||||
} else {
|
||||
side = mei.findEle(parentObj.children![start].id).closest('me-main')!.className as DirectionClass
|
||||
}
|
||||
return side
|
||||
}
|
||||
|
||||
const drawSummary = function (mei: MindElixirInstance, summary: Summary) {
|
||||
const { id, label: summaryText, parent, start, end } = summary
|
||||
const { nodes, theme, summarySvg } = mei
|
||||
const parentEl = mei.findEle(parent)
|
||||
const parentObj = parentEl.nodeObj
|
||||
const side = getDirection(mei, summary)
|
||||
let left = Infinity
|
||||
let right = 0
|
||||
let startTop = 0
|
||||
let endBottom = 0
|
||||
for (let i = start; i <= end; i++) {
|
||||
const child = parentObj.children?.[i]
|
||||
if (!child) {
|
||||
console.warn('Child not found')
|
||||
mei.removeSummary(id)
|
||||
return null
|
||||
}
|
||||
const wrapper = getWrapper(mei.findEle(child.id))
|
||||
const { offsetLeft, offsetTop } = getOffsetLT(nodes, wrapper)
|
||||
const offset = start === end ? 10 : 20
|
||||
if (i === start) startTop = offsetTop + offset
|
||||
if (i === end) endBottom = offsetTop + wrapper.offsetHeight - offset
|
||||
if (offsetLeft < left) left = offsetLeft
|
||||
if (wrapper.offsetWidth + offsetLeft > right) right = wrapper.offsetWidth + offsetLeft
|
||||
}
|
||||
let path
|
||||
let text
|
||||
const top = startTop + 10
|
||||
const bottom = endBottom + 10
|
||||
const md = (top + bottom) / 2
|
||||
const color = theme.cssVar['--color']
|
||||
if (side === DirectionClass.LHS) {
|
||||
path = createPath(`M ${left + 10} ${top} c -5 0 -10 5 -10 10 L ${left} ${bottom - 10} c 0 5 5 10 10 10 M ${left} ${md} h -10`, color)
|
||||
text = createSvgText(summaryText, left - 20, md + 6, { anchor: 'end', color })
|
||||
} else {
|
||||
path = createPath(`M ${right - 10} ${top} c 5 0 10 5 10 10 L ${right} ${bottom - 10} c 0 5 -5 10 -10 10 M ${right} ${md} h 10`, color)
|
||||
text = createSvgText(summaryText, right + 20, md + 6, { anchor: 'start', color })
|
||||
}
|
||||
const group = creatGroup('s-' + id)
|
||||
group.appendChild(path)
|
||||
group.appendChild(text)
|
||||
group.summaryObj = summary
|
||||
summarySvg.appendChild(group)
|
||||
return group
|
||||
}
|
||||
|
||||
export const createSummary = function (this: MindElixirInstance) {
|
||||
if (!this.currentNodes) return
|
||||
const { currentNodes: nodes, summaries, bus } = this
|
||||
const { parent, start, end } = calcRange(nodes)
|
||||
const summary = { id: generateUUID(), parent, start, end, label: 'summary' }
|
||||
const g = drawSummary(this, summary) as SummarySvgGroup
|
||||
summaries.push(summary)
|
||||
this.editSummary(g)
|
||||
bus.fire('operation', {
|
||||
name: 'createSummary',
|
||||
obj: summary,
|
||||
})
|
||||
}
|
||||
|
||||
export const createSummaryFrom = function (this: MindElixirInstance, summary: Omit<Summary, 'id'>) {
|
||||
// now I know the goodness of overloading
|
||||
const id = generateUUID()
|
||||
const newSummary = { ...summary, id }
|
||||
drawSummary(this, newSummary)
|
||||
this.summaries.push(newSummary)
|
||||
this.bus.fire('operation', {
|
||||
name: 'createSummary',
|
||||
obj: newSummary,
|
||||
})
|
||||
}
|
||||
|
||||
export const removeSummary = function (this: MindElixirInstance, id: string) {
|
||||
const index = this.summaries.findIndex(summary => summary.id === id)
|
||||
if (index > -1) {
|
||||
this.summaries.splice(index, 1)
|
||||
document.querySelector('#s-' + id)?.remove()
|
||||
}
|
||||
this.bus.fire('operation', {
|
||||
name: 'removeSummary',
|
||||
obj: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export const selectSummary = function (this: MindElixirInstance, el: SummarySvgGroup) {
|
||||
const box = el.children[1].getBBox()
|
||||
const padding = 6
|
||||
const radius = 3
|
||||
const rect = document.createElementNS(svgNS, 'rect')
|
||||
setAttributes(rect, {
|
||||
x: box.x - padding + '',
|
||||
y: box.y - padding + '',
|
||||
width: box.width + padding * 2 + '',
|
||||
height: box.height + padding * 2 + '',
|
||||
rx: radius + '',
|
||||
stroke: this.theme.cssVar['--selected'] || '#4dc4ff',
|
||||
'stroke-width': '2',
|
||||
fill: 'none',
|
||||
})
|
||||
el.appendChild(rect)
|
||||
this.currentSummary = el
|
||||
}
|
||||
|
||||
export const unselectSummary = function (this: MindElixirInstance) {
|
||||
this.currentSummary?.querySelector('rect')?.remove()
|
||||
this.currentSummary = null
|
||||
}
|
||||
|
||||
export const renderSummary = function (this: MindElixirInstance) {
|
||||
this.summarySvg.innerHTML = ''
|
||||
this.summaries.forEach(summary => {
|
||||
try {
|
||||
drawSummary(this, summary)
|
||||
} catch (e) {
|
||||
console.warn('Node may not be expanded')
|
||||
}
|
||||
})
|
||||
this.nodes.insertAdjacentElement('beforeend', this.summarySvg)
|
||||
}
|
||||
|
||||
export const editSummary = function (this: MindElixirInstance, el: SummarySvgGroup) {
|
||||
console.time('editSummary')
|
||||
if (!el) return
|
||||
const textEl = el.childNodes[1] as SVGTextElement
|
||||
editSvgText(this, textEl, el.summaryObj)
|
||||
console.timeEnd('editSummary')
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import type { Arrow } from '../arrow'
|
||||
import type { NodeObj } from './index'
|
||||
|
||||
export interface Wrapper extends HTMLElement {
|
||||
firstChild: Parent
|
||||
children: HTMLCollection & [Parent, Children]
|
||||
parentNode: Children
|
||||
parentElement: Children
|
||||
offsetParent: Wrapper
|
||||
previousSibling: Wrapper | null
|
||||
nextSibling: Wrapper | null
|
||||
}
|
||||
|
||||
export interface Parent extends HTMLElement {
|
||||
firstChild: Topic
|
||||
children: HTMLCollection & [Topic, Expander | undefined]
|
||||
parentNode: Wrapper
|
||||
parentElement: Wrapper
|
||||
nextSibling: Children
|
||||
offsetParent: Wrapper
|
||||
}
|
||||
|
||||
export interface Children extends HTMLElement {
|
||||
parentNode: Wrapper
|
||||
children: HTMLCollection & Wrapper[]
|
||||
parentElement: Wrapper
|
||||
firstChild: Wrapper
|
||||
previousSibling: Parent
|
||||
}
|
||||
|
||||
export interface Topic extends HTMLElement {
|
||||
nodeObj: NodeObj
|
||||
parentNode: Parent
|
||||
parentElement: Parent
|
||||
offsetParent: Parent
|
||||
|
||||
text: HTMLSpanElement
|
||||
expander?: Expander
|
||||
|
||||
link?: HTMLElement
|
||||
image?: HTMLImageElement
|
||||
icons?: HTMLSpanElement
|
||||
tags?: HTMLDivElement
|
||||
}
|
||||
|
||||
export interface Expander extends HTMLElement {
|
||||
expanded?: boolean
|
||||
parentNode: Parent
|
||||
parentElement: Parent
|
||||
previousSibling: Topic
|
||||
}
|
||||
|
||||
export type CustomLine = SVGPathElement
|
||||
export type CustomArrow = SVGPathElement
|
||||
export interface CustomSvg extends SVGGElement {
|
||||
arrowObj: Arrow
|
||||
label: SVGTextElement
|
||||
line: SVGPathElement
|
||||
arrow1: SVGPathElement
|
||||
arrow2: SVGPathElement
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
export {}
|
||||
declare global {
|
||||
interface Element {
|
||||
setAttribute(name: string, value: boolean): void
|
||||
setAttribute(name: string, value: number): void
|
||||
}
|
||||
}
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
import type { Topic, CustomSvg } from './dom'
|
||||
import type { createBus, EventMap, Operation } from '../utils/pubsub'
|
||||
import type { MindElixirMethods, OperationMap, Operations } from '../methods'
|
||||
import type { LinkDragMoveHelperInstance } from '../utils/LinkDragMoveHelper'
|
||||
import type { Arrow } from '../arrow'
|
||||
import type { Summary, SummarySvgGroup } from '../summary'
|
||||
import type { MainLineParams, SubLineParams } from '../utils/generateBranch'
|
||||
import type { Locale } from '../i18n'
|
||||
import type { ContextMenuOption } from '../plugin/contextMenu'
|
||||
import type { createDragMoveHelper } from '../utils/dragMoveHelper'
|
||||
import type SelectionArea from '@viselect/vanilla'
|
||||
export { type MindElixirMethods } from '../methods'
|
||||
|
||||
export enum DirectionClass {
|
||||
LHS = 'lhs',
|
||||
RHS = 'rhs',
|
||||
}
|
||||
|
||||
type Before = Partial<{
|
||||
[K in Operations]: (...args: Parameters<OperationMap[K]>) => Promise<boolean> | boolean
|
||||
}>
|
||||
|
||||
/**
|
||||
* MindElixir Theme
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type Theme = {
|
||||
name: string
|
||||
/**
|
||||
* Hint for developers to use the correct theme
|
||||
*/
|
||||
type?: 'light' | 'dark'
|
||||
/**
|
||||
* Color palette for main branches
|
||||
*/
|
||||
palette: string[]
|
||||
cssVar: {
|
||||
'--node-gap-x': string
|
||||
'--node-gap-y': string
|
||||
'--main-gap-x': string
|
||||
'--main-gap-y': string
|
||||
'--main-color': string
|
||||
'--main-bgcolor': string
|
||||
'--color': string
|
||||
'--bgcolor': string
|
||||
'--selected': string
|
||||
'--accent-color': string
|
||||
'--root-color': string
|
||||
'--root-bgcolor': string
|
||||
'--root-border-color': string
|
||||
'--root-radius': string
|
||||
'--main-radius': string
|
||||
'--topic-padding': string
|
||||
'--panel-color': string
|
||||
'--panel-bgcolor': string
|
||||
'--panel-border-color': string
|
||||
'--map-padding': string
|
||||
}
|
||||
}
|
||||
|
||||
export type Alignment = 'root' | 'nodes'
|
||||
|
||||
export interface KeypressOptions {
|
||||
[key: string]: (e: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The MindElixir instance
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface MindElixirInstance extends Omit<Required<Options>, 'markdown' | 'imageProxy'>, MindElixirMethods {
|
||||
markdown?: (markdown: string, obj: NodeObj) => string // Keep markdown as optional
|
||||
imageProxy?: (url: string) => string // Keep imageProxy as optional
|
||||
dragged: Topic[] | null // currently dragged nodes
|
||||
el: HTMLElement
|
||||
disposable: Array<() => void>
|
||||
isFocusMode: boolean
|
||||
nodeDataBackup: NodeObj
|
||||
|
||||
nodeData: NodeObj
|
||||
arrows: Arrow[]
|
||||
summaries: Summary[]
|
||||
|
||||
readonly currentNode: Topic | null
|
||||
currentNodes: Topic[]
|
||||
currentSummary: SummarySvgGroup | null
|
||||
currentArrow: CustomSvg | null
|
||||
waitCopy: Topic[] | null
|
||||
|
||||
scaleVal: number
|
||||
tempDirection: number | null
|
||||
|
||||
container: HTMLElement
|
||||
map: HTMLElement
|
||||
root: HTMLElement
|
||||
nodes: HTMLElement
|
||||
lines: SVGElement
|
||||
summarySvg: SVGElement
|
||||
linkController: SVGElement
|
||||
P2: HTMLElement
|
||||
P3: HTMLElement
|
||||
line1: SVGElement
|
||||
line2: SVGElement
|
||||
linkSvgGroup: SVGElement
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
helper1?: LinkDragMoveHelperInstance
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
helper2?: LinkDragMoveHelperInstance
|
||||
|
||||
bus: ReturnType<typeof createBus<EventMap>>
|
||||
history: Operation[]
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
|
||||
selection: SelectionArea
|
||||
dragMoveHelper: ReturnType<typeof createDragMoveHelper>
|
||||
}
|
||||
type PathString = string
|
||||
/**
|
||||
* The MindElixir options
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface Options {
|
||||
el: string | HTMLElement
|
||||
direction?: number
|
||||
locale?: Locale
|
||||
draggable?: boolean
|
||||
editable?: boolean
|
||||
contextMenu?: boolean | ContextMenuOption
|
||||
toolBar?: boolean
|
||||
keypress?: boolean | KeypressOptions
|
||||
mouseSelectionButton?: 0 | 2
|
||||
before?: Before
|
||||
newTopicName?: string
|
||||
allowUndo?: boolean
|
||||
overflowHidden?: boolean
|
||||
generateMainBranch?: (this: MindElixirInstance, params: MainLineParams) => PathString
|
||||
generateSubBranch?: (this: MindElixirInstance, params: SubLineParams) => PathString
|
||||
theme?: Theme
|
||||
selectionContainer?: string | HTMLElement
|
||||
alignment?: Alignment
|
||||
scaleSensitivity?: number
|
||||
scaleMin?: number
|
||||
scaleMax?: number
|
||||
handleWheel?: true | ((e: WheelEvent) => void)
|
||||
/**
|
||||
* Custom markdown parser function that takes markdown string and returns HTML string
|
||||
* If not provided, markdown will be disabled
|
||||
* @default undefined
|
||||
*/
|
||||
markdown?: (markdown: string, obj: NodeObj) => string
|
||||
/**
|
||||
* Image proxy function to handle image URLs, mainly used to solve CORS issues
|
||||
* If provided, all image URLs will be processed through this function before setting to img src
|
||||
* @default undefined
|
||||
*/
|
||||
imageProxy?: (url: string) => string
|
||||
}
|
||||
|
||||
export type Uid = string
|
||||
|
||||
export type Left = 0
|
||||
export type Right = 1
|
||||
|
||||
/**
|
||||
* Tag object for node tags with optional styling
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface TagObj {
|
||||
text: string
|
||||
style?: Partial<CSSStyleDeclaration> | Record<string, string>
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* MindElixir node object
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface NodeObj {
|
||||
topic: string
|
||||
id: Uid
|
||||
style?: Partial<{
|
||||
fontSize: string
|
||||
fontFamily: string
|
||||
color: string
|
||||
background: string
|
||||
fontWeight: string
|
||||
width: string
|
||||
border: string
|
||||
textDecoration: string
|
||||
}>
|
||||
children?: NodeObj[]
|
||||
tags?: (string | TagObj)[]
|
||||
icons?: string[]
|
||||
hyperLink?: string
|
||||
expanded?: boolean
|
||||
direction?: Left | Right
|
||||
image?: {
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
fit?: 'fill' | 'contain' | 'cover'
|
||||
}
|
||||
/**
|
||||
* The color of the branch.
|
||||
*/
|
||||
branchColor?: string
|
||||
/**
|
||||
* This property is added programatically, do not set it manually.
|
||||
*
|
||||
* the Root node has no parent!
|
||||
*/
|
||||
parent?: NodeObj
|
||||
/**
|
||||
* Render custom HTML in the node.
|
||||
*
|
||||
* Everything in the node will be replaced by this property.
|
||||
*/
|
||||
dangerouslySetInnerHTML?: string
|
||||
/**
|
||||
* Extra data for the node, which can be used to store any custom data.
|
||||
*/
|
||||
note?: string
|
||||
// TODO: checkbox
|
||||
// checkbox?: boolean | undefined
|
||||
}
|
||||
export type NodeObjExport = Omit<NodeObj, 'parent'>
|
||||
|
||||
/**
|
||||
* The exported data of MindElixir
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type MindElixirData = {
|
||||
nodeData: NodeObj
|
||||
arrows?: Arrow[]
|
||||
summaries?: Summary[]
|
||||
direction?: number
|
||||
theme?: Theme
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { on } from '.'
|
||||
|
||||
const create = function (dom: HTMLElement) {
|
||||
return {
|
||||
dom,
|
||||
moved: false, // differentiate click and move
|
||||
pointerdown: false,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
handlePointerMove(e: PointerEvent) {
|
||||
if (this.pointerdown) {
|
||||
this.moved = true
|
||||
// Calculate delta manually since pointer events don't have movementX/Y
|
||||
const deltaX = e.clientX - this.lastX
|
||||
const deltaY = e.clientY - this.lastY
|
||||
this.lastX = e.clientX
|
||||
this.lastY = e.clientY
|
||||
this.cb && this.cb(deltaX, deltaY)
|
||||
}
|
||||
},
|
||||
handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
this.pointerdown = true
|
||||
this.lastX = e.clientX
|
||||
this.lastY = e.clientY
|
||||
// Set pointer capture for better tracking
|
||||
this.dom.setPointerCapture(e.pointerId)
|
||||
},
|
||||
handleClear(e: PointerEvent) {
|
||||
this.pointerdown = false
|
||||
// Release pointer capture
|
||||
if (e.pointerId !== undefined) {
|
||||
this.dom.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
},
|
||||
cb: null as ((deltaX: number, deltaY: number) => void) | null,
|
||||
init(map: HTMLElement, cb: (deltaX: number, deltaY: number) => void) {
|
||||
this.cb = cb
|
||||
this.handleClear = this.handleClear.bind(this)
|
||||
this.handlePointerMove = this.handlePointerMove.bind(this)
|
||||
this.handlePointerDown = this.handlePointerDown.bind(this)
|
||||
this.destroy = on([
|
||||
{ dom: map, evt: 'pointermove', func: this.handlePointerMove },
|
||||
{ dom: map, evt: 'pointerleave', func: this.handleClear },
|
||||
{ dom: map, evt: 'pointerup', func: this.handleClear },
|
||||
{ dom: this.dom, evt: 'pointerdown', func: this.handlePointerDown },
|
||||
])
|
||||
},
|
||||
destroy: null as (() => void) | null,
|
||||
clear() {
|
||||
this.moved = false
|
||||
this.pointerdown = false
|
||||
},
|
||||
}
|
||||
}
|
||||
const LinkDragMoveHelper = {
|
||||
create,
|
||||
}
|
||||
|
||||
export type LinkDragMoveHelperInstance = ReturnType<typeof create>
|
||||
export default LinkDragMoveHelper
|
||||
|
|
@ -1,248 +0,0 @@
|
|||
import { LEFT } from '../const'
|
||||
import type { Topic, Wrapper, Parent, Children, Expander } from '../types/dom'
|
||||
import type { MindElixirInstance, NodeObj } from '../types/index'
|
||||
import { encodeHTML } from '../utils/index'
|
||||
import { layoutChildren } from './layout'
|
||||
|
||||
// DOM manipulation
|
||||
const $d = document
|
||||
export const findEle = function (this: MindElixirInstance, id: string, el?: HTMLElement) {
|
||||
const scope = this?.el ? this.el : el ? el : document
|
||||
const ele = scope.querySelector<Topic>(`[data-nodeid="me${id}"]`)
|
||||
if (!ele) throw new Error(`FindEle: Node ${id} not found, maybe it's collapsed.`)
|
||||
return ele
|
||||
}
|
||||
|
||||
export const shapeTpc = function (this: MindElixirInstance, tpc: Topic, nodeObj: NodeObj) {
|
||||
tpc.innerHTML = ''
|
||||
|
||||
if (nodeObj.style) {
|
||||
const style = nodeObj.style
|
||||
type KeyOfStyle = keyof typeof style
|
||||
for (const key in style) {
|
||||
tpc.style[key as KeyOfStyle] = style[key as KeyOfStyle]!
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeObj.dangerouslySetInnerHTML) {
|
||||
tpc.innerHTML = nodeObj.dangerouslySetInnerHTML
|
||||
return
|
||||
}
|
||||
|
||||
if (nodeObj.image) {
|
||||
const img = nodeObj.image
|
||||
if (img.url && img.width && img.height) {
|
||||
const imgEl = $d.createElement('img')
|
||||
// Use imageProxy function if provided, otherwise use original URL
|
||||
imgEl.src = this.imageProxy ? this.imageProxy(img.url) : img.url
|
||||
imgEl.style.width = img.width + 'px'
|
||||
imgEl.style.height = img.height + 'px'
|
||||
if (img.fit) imgEl.style.objectFit = img.fit
|
||||
tpc.appendChild(imgEl)
|
||||
tpc.image = imgEl
|
||||
} else {
|
||||
console.warn('Image url/width/height are required')
|
||||
}
|
||||
} else if (tpc.image) {
|
||||
tpc.image = undefined
|
||||
}
|
||||
|
||||
{
|
||||
const textEl = $d.createElement('span')
|
||||
textEl.className = 'text'
|
||||
|
||||
// Check if markdown parser is provided and topic contains markdown syntax
|
||||
if (this.markdown) {
|
||||
textEl.innerHTML = this.markdown(nodeObj.topic, nodeObj)
|
||||
} else {
|
||||
textEl.textContent = nodeObj.topic
|
||||
}
|
||||
|
||||
tpc.appendChild(textEl)
|
||||
tpc.text = textEl
|
||||
}
|
||||
|
||||
if (nodeObj.hyperLink) {
|
||||
const linkEl = $d.createElement('a')
|
||||
linkEl.className = 'hyper-link'
|
||||
linkEl.target = '_blank'
|
||||
linkEl.innerText = '🔗'
|
||||
linkEl.href = nodeObj.hyperLink
|
||||
tpc.appendChild(linkEl)
|
||||
tpc.link = linkEl
|
||||
} else if (tpc.link) {
|
||||
tpc.link = undefined
|
||||
}
|
||||
|
||||
if (nodeObj.icons && nodeObj.icons.length) {
|
||||
const iconsEl = $d.createElement('span')
|
||||
iconsEl.className = 'icons'
|
||||
iconsEl.innerHTML = nodeObj.icons.map(icon => `<span>${encodeHTML(icon)}</span>`).join('')
|
||||
tpc.appendChild(iconsEl)
|
||||
tpc.icons = iconsEl
|
||||
} else if (tpc.icons) {
|
||||
tpc.icons = undefined
|
||||
}
|
||||
|
||||
if (nodeObj.tags && nodeObj.tags.length) {
|
||||
const tagsEl = $d.createElement('div')
|
||||
tagsEl.className = 'tags'
|
||||
|
||||
nodeObj.tags.forEach(tag => {
|
||||
const span = $d.createElement('span')
|
||||
|
||||
if (typeof tag === 'string') {
|
||||
span.textContent = tag
|
||||
} else {
|
||||
span.textContent = tag.text
|
||||
if (tag.className) {
|
||||
span.className = tag.className
|
||||
}
|
||||
if (tag.style) {
|
||||
Object.assign(span.style, tag.style)
|
||||
}
|
||||
}
|
||||
|
||||
tagsEl.appendChild(span)
|
||||
})
|
||||
|
||||
tpc.appendChild(tagsEl)
|
||||
tpc.tags = tagsEl
|
||||
} else if (tpc.tags) {
|
||||
tpc.tags = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// everything start from `Wrapper`
|
||||
export const createWrapper = function (this: MindElixirInstance, nodeObj: NodeObj, omitChildren?: boolean) {
|
||||
const grp = $d.createElement('me-wrapper') as Wrapper
|
||||
const { p, tpc } = this.createParent(nodeObj)
|
||||
grp.appendChild(p)
|
||||
if (!omitChildren && nodeObj.children && nodeObj.children.length > 0) {
|
||||
const expander = createExpander(nodeObj.expanded)
|
||||
p.appendChild(expander)
|
||||
// tpc.expander = expander
|
||||
if (nodeObj.expanded !== false) {
|
||||
const children = layoutChildren(this, nodeObj.children)
|
||||
grp.appendChild(children)
|
||||
}
|
||||
}
|
||||
return { grp, top: p, tpc }
|
||||
}
|
||||
|
||||
export const createParent = function (this: MindElixirInstance, nodeObj: NodeObj) {
|
||||
const p = $d.createElement('me-parent') as Parent
|
||||
const tpc = this.createTopic(nodeObj)
|
||||
shapeTpc.call(this, tpc, nodeObj)
|
||||
p.appendChild(tpc)
|
||||
return { p, tpc }
|
||||
}
|
||||
|
||||
export const createChildren = function (this: MindElixirInstance, wrappers: Wrapper[]) {
|
||||
const children = $d.createElement('me-children') as Children
|
||||
children.append(...wrappers)
|
||||
return children
|
||||
}
|
||||
|
||||
export const createTopic = function (this: MindElixirInstance, nodeObj: NodeObj) {
|
||||
const topic = $d.createElement('me-tpc') as Topic
|
||||
topic.nodeObj = nodeObj
|
||||
topic.dataset.nodeid = 'me' + nodeObj.id
|
||||
topic.draggable = this.draggable
|
||||
return topic
|
||||
}
|
||||
|
||||
export function selectText(div: HTMLElement) {
|
||||
const range = $d.createRange()
|
||||
range.selectNodeContents(div)
|
||||
const getSelection = window.getSelection()
|
||||
if (getSelection) {
|
||||
getSelection.removeAllRanges()
|
||||
getSelection.addRange(range)
|
||||
}
|
||||
}
|
||||
|
||||
export const editTopic = function (this: MindElixirInstance, el: Topic) {
|
||||
console.time('editTopic')
|
||||
if (!el) return
|
||||
const div = $d.createElement('div')
|
||||
const node = el.nodeObj
|
||||
|
||||
// Get the original content from topic
|
||||
const originalContent = node.topic
|
||||
|
||||
el.appendChild(div)
|
||||
div.id = 'input-box'
|
||||
div.textContent = originalContent
|
||||
div.contentEditable = 'plaintext-only'
|
||||
div.spellcheck = false
|
||||
const style = getComputedStyle(el)
|
||||
div.style.cssText = `min-width:${el.offsetWidth - 8}px;
|
||||
color:${style.color};
|
||||
padding:${style.padding};
|
||||
margin:${style.margin};
|
||||
font:${style.font};
|
||||
background-color:${style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor};
|
||||
border-radius:${style.borderRadius};`
|
||||
if (this.direction === LEFT) div.style.right = '0'
|
||||
|
||||
selectText(div)
|
||||
|
||||
this.bus.fire('operation', {
|
||||
name: 'beginEdit',
|
||||
obj: el.nodeObj,
|
||||
})
|
||||
|
||||
div.addEventListener('keydown', e => {
|
||||
e.stopPropagation()
|
||||
const key = e.key
|
||||
|
||||
if (key === 'Enter' || key === 'Tab') {
|
||||
// keep wrap for shift enter
|
||||
if (e.shiftKey) return
|
||||
|
||||
e.preventDefault()
|
||||
div.blur()
|
||||
this.container.focus()
|
||||
}
|
||||
})
|
||||
|
||||
div.addEventListener('blur', () => {
|
||||
if (!div) return
|
||||
const inputContent = div.textContent?.trim() || ''
|
||||
|
||||
if (inputContent === '') {
|
||||
node.topic = originalContent
|
||||
} else {
|
||||
// Update topic content
|
||||
node.topic = inputContent
|
||||
|
||||
if (this.markdown) {
|
||||
el.text.innerHTML = this.markdown(node.topic, node)
|
||||
} else {
|
||||
// Plain text content
|
||||
el.text.textContent = inputContent
|
||||
}
|
||||
}
|
||||
|
||||
div.remove()
|
||||
|
||||
if (inputContent === originalContent) return
|
||||
|
||||
this.linkDiv()
|
||||
this.bus.fire('operation', {
|
||||
name: 'finishEdit',
|
||||
obj: node,
|
||||
origin: originalContent,
|
||||
})
|
||||
})
|
||||
console.timeEnd('editTopic')
|
||||
}
|
||||
|
||||
export const createExpander = function (expanded: boolean | undefined): Expander {
|
||||
const expander = $d.createElement('me-epd') as Expander
|
||||
// if expanded is undefined, treat as expanded
|
||||
expander.expanded = expanded !== false
|
||||
expander.className = expanded !== false ? 'minus' : ''
|
||||
return expander
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { LEFT, RIGHT, SIDE } from '../const'
|
||||
import { rmSubline } from '../nodeOperation'
|
||||
import type { MindElixirInstance, NodeObj } from '../types'
|
||||
import type { Topic, Wrapper } from '../types/dom'
|
||||
import { createExpander } from './dom'
|
||||
|
||||
// Judge new added node L or R
|
||||
export const judgeDirection = function ({ map, direction }: MindElixirInstance, obj: NodeObj) {
|
||||
if (direction === LEFT) {
|
||||
return LEFT
|
||||
} else if (direction === RIGHT) {
|
||||
return RIGHT
|
||||
} else if (direction === SIDE) {
|
||||
const l = map.querySelector('.lhs')?.childElementCount || 0
|
||||
const r = map.querySelector('.rhs')?.childElementCount || 0
|
||||
if (l <= r) {
|
||||
obj.direction = LEFT
|
||||
return LEFT
|
||||
} else {
|
||||
obj.direction = RIGHT
|
||||
return RIGHT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const addChildDom = function (mei: MindElixirInstance, to: Topic, wrapper: Wrapper) {
|
||||
const tpc = wrapper.children[0].children[0]
|
||||
const top = to.parentElement
|
||||
if (top.tagName === 'ME-PARENT') {
|
||||
rmSubline(tpc)
|
||||
if (top.children[1]) {
|
||||
top.nextSibling.appendChild(wrapper)
|
||||
} else {
|
||||
const c = mei.createChildren([wrapper])
|
||||
top.appendChild(createExpander(true))
|
||||
top.insertAdjacentElement('afterend', c)
|
||||
}
|
||||
mei.linkDiv(wrapper.offsetParent as Wrapper)
|
||||
} else if (top.tagName === 'ME-ROOT') {
|
||||
const direction = judgeDirection(mei, tpc.nodeObj)
|
||||
if (direction === LEFT) {
|
||||
mei.container.querySelector('.lhs')?.appendChild(wrapper)
|
||||
} else {
|
||||
mei.container.querySelector('.rhs')?.appendChild(wrapper)
|
||||
}
|
||||
mei.linkDiv()
|
||||
}
|
||||
}
|
||||
|
||||
export const removeNodeDom = function (tpc: Topic, siblingLength: number) {
|
||||
const p = tpc.parentNode
|
||||
if (siblingLength === 0) {
|
||||
// remove epd when children length === 0
|
||||
const c = p.parentNode.parentNode
|
||||
if (c.tagName !== 'ME-MAIN') {
|
||||
// Root
|
||||
c.previousSibling.children[1]!.remove() // remove epd
|
||||
c.remove() // remove Children div
|
||||
}
|
||||
}
|
||||
p.parentNode.remove()
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import type { MindElixirInstance } from '../types/index'
|
||||
|
||||
export function createDragMoveHelper(mei: MindElixirInstance) {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
moved: false, // diffrentiate click and move
|
||||
mousedown: false,
|
||||
onMove(deltaX: number, deltaY: number) {
|
||||
if (this.mousedown) {
|
||||
this.moved = true
|
||||
mei.move(deltaX, deltaY)
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
this.mousedown = false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import type { MindElixirInstance } from '..'
|
||||
import { DirectionClass } from '../types/index'
|
||||
|
||||
export interface MainLineParams {
|
||||
pT: number
|
||||
pL: number
|
||||
pW: number
|
||||
pH: number
|
||||
cT: number
|
||||
cL: number
|
||||
cW: number
|
||||
cH: number
|
||||
direction: DirectionClass
|
||||
containerHeight: number
|
||||
}
|
||||
|
||||
export interface SubLineParams {
|
||||
pT: number
|
||||
pL: number
|
||||
pW: number
|
||||
pH: number
|
||||
cT: number
|
||||
cL: number
|
||||
cW: number
|
||||
cH: number
|
||||
direction: DirectionClass
|
||||
isFirst: boolean | undefined
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d#path_commands
|
||||
|
||||
export function main({ pT, pL, pW, pH, cT, cL, cW, cH, direction, containerHeight }: MainLineParams) {
|
||||
let x1 = pL + pW / 2
|
||||
const y1 = pT + pH / 2
|
||||
let x2
|
||||
if (direction === DirectionClass.LHS) {
|
||||
x2 = cL + cW
|
||||
} else {
|
||||
x2 = cL
|
||||
}
|
||||
const y2 = cT + cH / 2
|
||||
const pct = Math.abs(y2 - y1) / containerHeight
|
||||
const offset = (1 - pct) * 0.25 * (pW / 2)
|
||||
if (direction === DirectionClass.LHS) {
|
||||
x1 = x1 - pW / 10 - offset
|
||||
} else {
|
||||
x1 = x1 + pW / 10 + offset
|
||||
}
|
||||
return `M ${x1} ${y1} Q ${x1} ${y2} ${x2} ${y2}`
|
||||
}
|
||||
|
||||
export function sub(this: MindElixirInstance, { pT, pL, pW, pH, cT, cL, cW, cH, direction, isFirst }: SubLineParams) {
|
||||
const GAP = parseInt(this.container.style.getPropertyValue('--node-gap-x')) // cache?
|
||||
// const GAP = 30
|
||||
let y1 = 0
|
||||
let end = 0
|
||||
if (isFirst) {
|
||||
y1 = pT + pH / 2
|
||||
} else {
|
||||
y1 = pT + pH
|
||||
}
|
||||
const y2 = cT + cH
|
||||
let x1 = 0
|
||||
let x2 = 0
|
||||
let xMid = 0
|
||||
const offset = (Math.abs(y1 - y2) / 300) * GAP
|
||||
if (direction === DirectionClass.LHS) {
|
||||
xMid = pL
|
||||
x1 = xMid + GAP
|
||||
x2 = xMid - GAP
|
||||
end = cL + GAP
|
||||
return `M ${x1} ${y1} C ${xMid} ${y1} ${xMid + offset} ${y2} ${x2} ${y2} H ${end}`
|
||||
} else {
|
||||
xMid = pL + pW
|
||||
x1 = xMid - GAP
|
||||
x2 = xMid + GAP
|
||||
end = cL + cW - GAP
|
||||
return `M ${x1} ${y1} C ${xMid} ${y1} ${xMid - offset} ${y2} ${x2} ${y2} H ${end}`
|
||||
}
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
import type { Topic } from '../types/dom'
|
||||
import type { NodeObj, MindElixirInstance, NodeObjExport } from '../types/index'
|
||||
|
||||
export function encodeHTML(s: string) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
export const isMobile = (): boolean => /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||
|
||||
export const getObjById = function (id: string, data: NodeObj): NodeObj | null {
|
||||
if (data.id === id) {
|
||||
return data
|
||||
} else if (data.children && data.children.length) {
|
||||
for (let i = 0; i < data.children.length; i++) {
|
||||
const res = getObjById(id, data.children[i])
|
||||
if (res) return res
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add parent property to every node
|
||||
*/
|
||||
export const fillParent = (data: NodeObj, parent?: NodeObj) => {
|
||||
data.parent = parent
|
||||
if (data.children) {
|
||||
for (let i = 0; i < data.children.length; i++) {
|
||||
fillParent(data.children[i], data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const setExpand = (node: NodeObj, isExpand: boolean, level?: number) => {
|
||||
node.expanded = isExpand
|
||||
if (node.children) {
|
||||
if (level === undefined || level > 0) {
|
||||
const nextLevel = level !== undefined ? level - 1 : undefined
|
||||
node.children.forEach(child => {
|
||||
setExpand(child, isExpand, nextLevel)
|
||||
})
|
||||
} else {
|
||||
node.children.forEach(child => {
|
||||
setExpand(child, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshIds(data: NodeObj) {
|
||||
data.id = generateUUID()
|
||||
if (data.children) {
|
||||
for (let i = 0; i < data.children.length; i++) {
|
||||
refreshIds(data.children[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const throttle = <T extends (...args: never[]) => void>(fn: T, wait: number) => {
|
||||
let pre = Date.now()
|
||||
return function (...args: Parameters<T>) {
|
||||
const now = Date.now()
|
||||
if (now - pre < wait) return
|
||||
fn(...args)
|
||||
pre = Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
export function getArrowPoints(p3x: number, p3y: number, p4x: number, p4y: number) {
|
||||
const deltay = p4y - p3y
|
||||
const deltax = p3x - p4x
|
||||
let angle = (Math.atan(Math.abs(deltay) / Math.abs(deltax)) / 3.14) * 180
|
||||
if (isNaN(angle)) return
|
||||
if (deltax < 0 && deltay > 0) {
|
||||
angle = 180 - angle
|
||||
}
|
||||
if (deltax < 0 && deltay < 0) {
|
||||
angle = 180 + angle
|
||||
}
|
||||
if (deltax > 0 && deltay < 0) {
|
||||
angle = 360 - angle
|
||||
}
|
||||
const arrowLength = 12
|
||||
const arrowAngle = 30
|
||||
const a1 = angle + arrowAngle
|
||||
const a2 = angle - arrowAngle
|
||||
return {
|
||||
x1: p4x + Math.cos((Math.PI * a1) / 180) * arrowLength,
|
||||
y1: p4y - Math.sin((Math.PI * a1) / 180) * arrowLength,
|
||||
x2: p4x + Math.cos((Math.PI * a2) / 180) * arrowLength,
|
||||
y2: p4y - Math.sin((Math.PI * a2) / 180) * arrowLength,
|
||||
}
|
||||
}
|
||||
|
||||
export function generateUUID(): string {
|
||||
return (new Date().getTime().toString(16) + Math.random().toString(16).substr(2)).substr(2, 16)
|
||||
}
|
||||
|
||||
export const generateNewObj = function (this: MindElixirInstance): NodeObjExport {
|
||||
const id = generateUUID()
|
||||
return {
|
||||
topic: this.newTopicName,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
export function checkMoveValid(from: NodeObj, to: NodeObj) {
|
||||
let valid = true
|
||||
while (to.parent) {
|
||||
if (to.parent === from) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
to = to.parent
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
export function deepClone(obj: NodeObj) {
|
||||
const deepCloneObj = JSON.parse(
|
||||
JSON.stringify(obj, (k, v) => {
|
||||
if (k === 'parent') return undefined
|
||||
return v
|
||||
})
|
||||
)
|
||||
return deepCloneObj
|
||||
}
|
||||
|
||||
export const getOffsetLT = (parent: HTMLElement, child: HTMLElement) => {
|
||||
let offsetLeft = 0
|
||||
let offsetTop = 0
|
||||
while (child && child !== parent) {
|
||||
offsetLeft += child.offsetLeft
|
||||
offsetTop += child.offsetTop
|
||||
child = child.offsetParent as HTMLElement
|
||||
}
|
||||
return { offsetLeft, offsetTop }
|
||||
}
|
||||
|
||||
export const setAttributes = (el: HTMLElement | SVGElement, attrs: { [key: string]: string }) => {
|
||||
for (const key in attrs) {
|
||||
el.setAttribute(key, attrs[key])
|
||||
}
|
||||
}
|
||||
|
||||
export const isTopic = (target?: HTMLElement): target is Topic => {
|
||||
return target ? target.tagName === 'ME-TPC' : false
|
||||
}
|
||||
|
||||
export const unionTopics = (nodes: Topic[]) => {
|
||||
return nodes
|
||||
.filter(node => node.nodeObj.parent)
|
||||
.filter((node, _, nodes) => {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (node === nodes[i]) continue
|
||||
const { parent } = node.nodeObj
|
||||
if (parent === nodes[i].nodeObj) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export const getTranslate = (styleText: string) => {
|
||||
const regex = /translate\(([^,]+),\s*([^)]+)\)/
|
||||
const match = styleText.match(regex)
|
||||
return match ? { x: parseFloat(match[1]), y: parseFloat(match[2]) } : { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
export const on = function (
|
||||
list: {
|
||||
[K in keyof GlobalEventHandlersEventMap]: {
|
||||
dom: EventTarget
|
||||
evt: K
|
||||
func: (this: EventTarget, ev: GlobalEventHandlersEventMap[K]) => void
|
||||
}
|
||||
}[keyof GlobalEventHandlersEventMap][]
|
||||
) {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const { dom, evt, func } = list[i]
|
||||
dom.addEventListener(evt, func as EventListener)
|
||||
}
|
||||
return function off() {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const { dom, evt, func } = list[i]
|
||||
dom.removeEventListener(evt, func as EventListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,321 +0,0 @@
|
|||
import { LEFT, RIGHT, SIDE } from '../const'
|
||||
import { DirectionClass, type NodeObj, type TagObj } from '../types/index'
|
||||
|
||||
/**
|
||||
* Server-side compatible layout data structure
|
||||
*/
|
||||
export interface SSRLayoutNode {
|
||||
id: string
|
||||
topic: string
|
||||
direction?: typeof LEFT | typeof RIGHT
|
||||
style?: {
|
||||
fontSize?: string
|
||||
color?: string
|
||||
background?: string
|
||||
fontWeight?: string
|
||||
}
|
||||
children?: SSRLayoutNode[]
|
||||
tags?: (string | TagObj)[]
|
||||
icons?: string[]
|
||||
hyperLink?: string
|
||||
expanded?: boolean
|
||||
image?: {
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
fit?: 'fill' | 'contain' | 'cover'
|
||||
}
|
||||
branchColor?: string
|
||||
dangerouslySetInnerHTML?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* SSR Layout result structure
|
||||
*/
|
||||
export interface SSRLayoutResult {
|
||||
root: SSRLayoutNode
|
||||
leftNodes: SSRLayoutNode[]
|
||||
rightNodes: SSRLayoutNode[]
|
||||
direction: number
|
||||
}
|
||||
|
||||
/**
|
||||
* SSR Layout options
|
||||
*/
|
||||
export interface SSRLayoutOptions {
|
||||
direction?: number
|
||||
newTopicName?: string
|
||||
}
|
||||
|
||||
const nodesWrapper = (nodesString: string) => {
|
||||
// don't add class="map-canvas" to prevent 20000px height
|
||||
return `<div class="map-container"><div>${nodesString}</div></div>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side compatible layout function for SSR
|
||||
* This function processes the mind map data structure without DOM manipulation
|
||||
*
|
||||
* @param nodeData - The root node data
|
||||
* @param options - Layout options including direction
|
||||
* @returns Structured layout data for server-side rendering
|
||||
*/
|
||||
export const layoutSSR = function (nodeData: NodeObj, options: SSRLayoutOptions = {}): SSRLayoutResult {
|
||||
const { direction = SIDE } = options
|
||||
|
||||
// Convert NodeObj to SSRLayoutNode (removing parent references for serialization)
|
||||
const convertToSSRNode = (node: NodeObj): SSRLayoutNode => {
|
||||
const ssrNode: SSRLayoutNode = {
|
||||
id: node.id,
|
||||
topic: node.topic,
|
||||
direction: node.direction,
|
||||
style: node.style,
|
||||
tags: node.tags,
|
||||
icons: node.icons,
|
||||
hyperLink: node.hyperLink,
|
||||
expanded: node.expanded,
|
||||
image: node.image,
|
||||
branchColor: node.branchColor,
|
||||
dangerouslySetInnerHTML: node.dangerouslySetInnerHTML,
|
||||
note: node.note,
|
||||
}
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
ssrNode.children = node.children.map(convertToSSRNode)
|
||||
}
|
||||
|
||||
return ssrNode
|
||||
}
|
||||
|
||||
// Create root node
|
||||
const root = convertToSSRNode(nodeData)
|
||||
|
||||
// Process main nodes (children of root)
|
||||
const mainNodes = nodeData.children || []
|
||||
const leftNodes: SSRLayoutNode[] = []
|
||||
const rightNodes: SSRLayoutNode[] = []
|
||||
|
||||
if (direction === SIDE) {
|
||||
// Distribute nodes between left and right sides
|
||||
let lcount = 0
|
||||
let rcount = 0
|
||||
|
||||
mainNodes.forEach(node => {
|
||||
const ssrNode = convertToSSRNode(node)
|
||||
|
||||
if (node.direction === LEFT) {
|
||||
ssrNode.direction = LEFT
|
||||
leftNodes.push(ssrNode)
|
||||
lcount += 1
|
||||
} else if (node.direction === RIGHT) {
|
||||
ssrNode.direction = RIGHT
|
||||
rightNodes.push(ssrNode)
|
||||
rcount += 1
|
||||
} else {
|
||||
// Auto-assign direction based on balance
|
||||
if (lcount <= rcount) {
|
||||
ssrNode.direction = LEFT
|
||||
leftNodes.push(ssrNode)
|
||||
lcount += 1
|
||||
} else {
|
||||
ssrNode.direction = RIGHT
|
||||
rightNodes.push(ssrNode)
|
||||
rcount += 1
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (direction === LEFT) {
|
||||
// All nodes go to left side
|
||||
mainNodes.forEach(node => {
|
||||
const ssrNode = convertToSSRNode(node)
|
||||
ssrNode.direction = LEFT
|
||||
leftNodes.push(ssrNode)
|
||||
})
|
||||
} else {
|
||||
// All nodes go to right side (RIGHT direction)
|
||||
mainNodes.forEach(node => {
|
||||
const ssrNode = convertToSSRNode(node)
|
||||
ssrNode.direction = RIGHT
|
||||
rightNodes.push(ssrNode)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
leftNodes,
|
||||
rightNodes,
|
||||
direction,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML string for server-side rendering
|
||||
* This function creates the HTML structure that would be generated by the DOM-based layout
|
||||
*
|
||||
* @param layoutResult - The result from layoutSSR function
|
||||
* @param options - Additional rendering options
|
||||
* @returns HTML string for server-side rendering
|
||||
*/
|
||||
export const renderSSRHTML = function (
|
||||
layoutResult: SSRLayoutResult,
|
||||
options: { className?: string; imageProxy?: (url: string) => string } = {}
|
||||
): string {
|
||||
const { className = '' } = options
|
||||
|
||||
const renderNode = (node: SSRLayoutNode, isRoot = false): string => {
|
||||
const nodeId = `me${node.id}`
|
||||
const topicClass = isRoot ? 'me-tpc' : 'me-tpc'
|
||||
|
||||
let styleAttr = ''
|
||||
if (node.style) {
|
||||
const styles: string[] = []
|
||||
if (node.style.color) styles.push(`color: ${node.style.color}`)
|
||||
if (node.style.background) styles.push(`background: ${node.style.background}`)
|
||||
if (node.style.fontSize) styles.push(`font-size: ${node.style.fontSize}px`)
|
||||
if (node.style.fontWeight) styles.push(`font-weight: ${node.style.fontWeight}`)
|
||||
if (styles.length > 0) {
|
||||
styleAttr = ` style="${styles.join('; ')}"`
|
||||
}
|
||||
}
|
||||
|
||||
let topicContent = ''
|
||||
if (node.dangerouslySetInnerHTML) {
|
||||
topicContent = node.dangerouslySetInnerHTML
|
||||
} else {
|
||||
topicContent = escapeHtml(node.topic)
|
||||
|
||||
// Add tags if present
|
||||
if (node.tags && node.tags.length > 0) {
|
||||
const tagsHtml = node.tags
|
||||
.map(tag => {
|
||||
if (typeof tag === 'string') {
|
||||
// Compatible with legacy string configuration
|
||||
return `<span class="me-tag">${escapeHtml(tag)}</span>`
|
||||
} else {
|
||||
// Support object configuration
|
||||
let classAttr = 'me-tag'
|
||||
if (tag.className) {
|
||||
classAttr += ` ${tag.className}`
|
||||
}
|
||||
|
||||
let styleAttr = ''
|
||||
if (tag.style) {
|
||||
const styles = Object.entries(tag.style)
|
||||
.filter(([_, value]) => value !== undefined && value !== null && value !== '')
|
||||
.map(([key, value]) => {
|
||||
// Convert camelCase to CSS property name
|
||||
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase()
|
||||
return `${cssKey}: ${value}`
|
||||
})
|
||||
|
||||
if (styles.length > 0) {
|
||||
styleAttr = ` style="${styles.join('; ')}"`
|
||||
}
|
||||
}
|
||||
|
||||
return `<span class="${classAttr}"${styleAttr}>${escapeHtml(tag.text)}</span>`
|
||||
}
|
||||
})
|
||||
.join('')
|
||||
topicContent += tagsHtml
|
||||
}
|
||||
|
||||
// Add icons if present
|
||||
if (node.icons && node.icons.length > 0) {
|
||||
const iconsHtml = node.icons.map(icon => `<span class="me-icon">${icon}</span>`).join('')
|
||||
topicContent += iconsHtml
|
||||
}
|
||||
|
||||
// Add image if present
|
||||
if (node.image) {
|
||||
const { url, width, height, fit = 'cover' } = node.image
|
||||
// Use imageProxy function if provided, otherwise use original URL
|
||||
const processedUrl = options.imageProxy ? options.imageProxy(url) : url
|
||||
topicContent += `<img src="${escapeHtml(processedUrl)}" width="${width}" height="${height}" style="object-fit: ${fit}" alt="" />`
|
||||
}
|
||||
}
|
||||
|
||||
const topicHtml = `<me-tpc class="${topicClass}" data-nodeid="${nodeId}"${styleAttr}>${topicContent}</me-tpc>`
|
||||
|
||||
if (isRoot) {
|
||||
return `<me-root>${topicHtml}</me-root>`
|
||||
}
|
||||
|
||||
let childrenHtml = ''
|
||||
if (node.children && node.children.length > 0 && node.expanded !== false) {
|
||||
const childWrappers = node.children.map(child => renderWrapper(child)).join('')
|
||||
childrenHtml = `<me-children>${childWrappers}</me-children>`
|
||||
}
|
||||
|
||||
const parentHtml = `<me-parent>${topicHtml}</me-parent>`
|
||||
return `<me-wrapper>${parentHtml}${childrenHtml}</me-wrapper>`
|
||||
}
|
||||
|
||||
const renderWrapper = (node: SSRLayoutNode): string => {
|
||||
return renderNode(node, false)
|
||||
}
|
||||
|
||||
const rootHtml = renderNode(layoutResult.root, true)
|
||||
|
||||
const leftWrappers = layoutResult.leftNodes.map(node => renderWrapper(node)).join('')
|
||||
const rightWrappers = layoutResult.rightNodes.map(node => renderWrapper(node)).join('')
|
||||
|
||||
const leftPartHtml = `<me-main class="${DirectionClass.LHS}">${leftWrappers}</me-main>`
|
||||
const rightPartHtml = `<me-main class="${DirectionClass.RHS}">${rightWrappers}</me-main>`
|
||||
|
||||
return nodesWrapper(`<div class="${className}">${leftPartHtml}${rootHtml}${rightPartHtml}</div>`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to escape HTML characters
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
const div = typeof document !== 'undefined' ? document.createElement('div') : null
|
||||
if (div) {
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
// Fallback for server-side
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON data structure for client-side hydration
|
||||
* This can be used to pass the layout data to the client for hydration
|
||||
*
|
||||
* @param layoutResult - The result from layoutSSR function
|
||||
* @returns JSON-serializable data structure
|
||||
*/
|
||||
export const getSSRData = function (layoutResult: SSRLayoutResult): string {
|
||||
return JSON.stringify(layoutResult, null, 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydration data structure for client-side initialization
|
||||
*/
|
||||
export interface HydrationData {
|
||||
nodeData: NodeObj
|
||||
layoutResult: SSRLayoutResult
|
||||
options: {
|
||||
direction: number
|
||||
[key: string]: any
|
||||
}
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete hydration data including original nodeData
|
||||
*/
|
||||
export const getHydrationData = function (nodeData: NodeObj, layoutResult: SSRLayoutResult, options: any = {}): HydrationData {
|
||||
return {
|
||||
nodeData,
|
||||
layoutResult,
|
||||
options: {
|
||||
direction: layoutResult.direction,
|
||||
...options,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import { LEFT, RIGHT, SIDE } from '../const'
|
||||
import type { Children } from '../types/dom'
|
||||
import { DirectionClass, type MindElixirInstance, type NodeObj } from '../types/index'
|
||||
import { shapeTpc } from './dom'
|
||||
|
||||
const $d = document
|
||||
|
||||
// Set main nodes' direction and invoke layoutChildren()
|
||||
export const layout = function (this: MindElixirInstance) {
|
||||
console.time('layout')
|
||||
this.nodes.innerHTML = ''
|
||||
|
||||
const tpc = this.createTopic(this.nodeData)
|
||||
shapeTpc.call(this, tpc, this.nodeData) // shape root tpc
|
||||
tpc.draggable = false
|
||||
const root = $d.createElement('me-root')
|
||||
root.appendChild(tpc)
|
||||
|
||||
const mainNodes = this.nodeData.children || []
|
||||
if (this.direction === SIDE) {
|
||||
// initiate direction of main nodes
|
||||
let lcount = 0
|
||||
let rcount = 0
|
||||
mainNodes.map(node => {
|
||||
if (node.direction === LEFT) {
|
||||
lcount += 1
|
||||
} else if (node.direction === RIGHT) {
|
||||
rcount += 1
|
||||
} else {
|
||||
if (lcount <= rcount) {
|
||||
node.direction = LEFT
|
||||
lcount += 1
|
||||
} else {
|
||||
node.direction = RIGHT
|
||||
rcount += 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
layoutMainNode(this, mainNodes, root)
|
||||
console.timeEnd('layout')
|
||||
}
|
||||
|
||||
const layoutMainNode = function (mei: MindElixirInstance, data: NodeObj[], root: HTMLElement) {
|
||||
const leftPart = $d.createElement('me-main')
|
||||
leftPart.className = DirectionClass.LHS
|
||||
const rightPart = $d.createElement('me-main')
|
||||
rightPart.className = DirectionClass.RHS
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const nodeObj = data[i]
|
||||
const { grp: w } = mei.createWrapper(nodeObj)
|
||||
if (mei.direction === SIDE) {
|
||||
if (nodeObj.direction === LEFT) {
|
||||
leftPart.appendChild(w)
|
||||
} else {
|
||||
rightPart.appendChild(w)
|
||||
}
|
||||
} else if (mei.direction === LEFT) {
|
||||
leftPart.appendChild(w)
|
||||
} else {
|
||||
rightPart.appendChild(w)
|
||||
}
|
||||
}
|
||||
|
||||
mei.nodes.appendChild(leftPart)
|
||||
mei.nodes.appendChild(root)
|
||||
mei.nodes.appendChild(rightPart)
|
||||
|
||||
mei.nodes.appendChild(mei.lines)
|
||||
}
|
||||
|
||||
export const layoutChildren = function (mei: MindElixirInstance, data: NodeObj[]) {
|
||||
const chldr = $d.createElement('me-children') as Children
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const nodeObj = data[i]
|
||||
const { grp } = mei.createWrapper(nodeObj)
|
||||
chldr.appendChild(grp)
|
||||
}
|
||||
return chldr
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import type { NodeObj } from '../types'
|
||||
|
||||
const getSibling = (obj: NodeObj): { siblings: NodeObj[] | undefined; index: number } => {
|
||||
const siblings = obj.parent?.children as NodeObj[]
|
||||
const index = siblings?.indexOf(obj) ?? 0
|
||||
return { siblings, index }
|
||||
}
|
||||
|
||||
export function moveUpObj(obj: NodeObj) {
|
||||
const { siblings, index } = getSibling(obj)
|
||||
if (siblings === undefined) return
|
||||
const t = siblings[index]
|
||||
if (index === 0) {
|
||||
siblings[index] = siblings[siblings.length - 1]
|
||||
siblings[siblings.length - 1] = t
|
||||
} else {
|
||||
siblings[index] = siblings[index - 1]
|
||||
siblings[index - 1] = t
|
||||
}
|
||||
}
|
||||
|
||||
export function moveDownObj(obj: NodeObj) {
|
||||
const { siblings, index } = getSibling(obj)
|
||||
if (siblings === undefined) return
|
||||
const t = siblings[index]
|
||||
if (index === siblings.length - 1) {
|
||||
siblings[index] = siblings[0]
|
||||
siblings[0] = t
|
||||
} else {
|
||||
siblings[index] = siblings[index + 1]
|
||||
siblings[index + 1] = t
|
||||
}
|
||||
}
|
||||
|
||||
export function removeNodeObj(obj: NodeObj) {
|
||||
const { siblings, index } = getSibling(obj)
|
||||
if (siblings === undefined) return 0
|
||||
siblings.splice(index, 1)
|
||||
return siblings.length
|
||||
}
|
||||
|
||||
export function insertNodeObj(newObj: NodeObj, type: 'before' | 'after', obj: NodeObj) {
|
||||
const { siblings, index } = getSibling(obj)
|
||||
if (siblings === undefined) return
|
||||
if (type === 'before') {
|
||||
siblings.splice(index, 0, newObj)
|
||||
} else {
|
||||
siblings.splice(index + 1, 0, newObj)
|
||||
}
|
||||
}
|
||||
|
||||
export function insertParentNodeObj(obj: NodeObj, newObj: NodeObj) {
|
||||
const { siblings, index } = getSibling(obj)
|
||||
if (siblings === undefined) return
|
||||
siblings[index] = newObj
|
||||
newObj.children = [obj]
|
||||
}
|
||||
|
||||
export function moveNodeObj(type: 'in' | 'before' | 'after', from: NodeObj, to: NodeObj) {
|
||||
removeNodeObj(from)
|
||||
if (!to.parent?.parent) {
|
||||
from.direction = to.direction
|
||||
}
|
||||
if (type === 'in') {
|
||||
if (to.children) to.children.push(from)
|
||||
else to.children = [from]
|
||||
} else {
|
||||
if (from.direction !== undefined) from.direction = to.direction
|
||||
const { siblings, index } = getSibling(to)
|
||||
if (siblings === undefined) return
|
||||
if (type === 'before') {
|
||||
siblings.splice(index, 0, from)
|
||||
} else {
|
||||
siblings.splice(index + 1, 0, from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import type { Arrow } from '../arrow'
|
||||
import type { Summary } from '../summary'
|
||||
import type { NodeObj } from '../types/index'
|
||||
|
||||
type NodeOperation =
|
||||
| {
|
||||
name: 'moveNodeIn' | 'moveDownNode' | 'moveUpNode' | 'copyNode' | 'addChild' | 'insertParent' | 'insertBefore' | 'beginEdit'
|
||||
obj: NodeObj
|
||||
}
|
||||
| {
|
||||
name: 'insertSibling'
|
||||
type: 'before' | 'after'
|
||||
obj: NodeObj
|
||||
}
|
||||
| {
|
||||
name: 'reshapeNode'
|
||||
obj: NodeObj
|
||||
origin: NodeObj
|
||||
}
|
||||
| {
|
||||
name: 'finishEdit'
|
||||
obj: NodeObj
|
||||
origin: string
|
||||
}
|
||||
| {
|
||||
name: 'moveNodeAfter' | 'moveNodeBefore' | 'moveNodeIn'
|
||||
objs: NodeObj[]
|
||||
toObj: NodeObj
|
||||
}
|
||||
|
||||
type MultipleNodeOperation =
|
||||
| {
|
||||
name: 'removeNodes'
|
||||
objs: NodeObj[]
|
||||
}
|
||||
| {
|
||||
name: 'copyNodes'
|
||||
objs: NodeObj[]
|
||||
}
|
||||
|
||||
export type SummaryOperation =
|
||||
| {
|
||||
name: 'createSummary'
|
||||
obj: Summary
|
||||
}
|
||||
| {
|
||||
name: 'removeSummary'
|
||||
obj: { id: string }
|
||||
}
|
||||
| {
|
||||
name: 'finishEditSummary'
|
||||
obj: Summary
|
||||
}
|
||||
|
||||
export type ArrowOperation =
|
||||
| {
|
||||
name: 'createArrow'
|
||||
obj: Arrow
|
||||
}
|
||||
| {
|
||||
name: 'removeArrow'
|
||||
obj: { id: string }
|
||||
}
|
||||
| {
|
||||
name: 'finishEditArrowLabel'
|
||||
obj: Arrow
|
||||
}
|
||||
|
||||
export type Operation = NodeOperation | MultipleNodeOperation | SummaryOperation | ArrowOperation
|
||||
export type OperationType = Operation['name']
|
||||
|
||||
export type EventMap = {
|
||||
operation: (info: Operation) => void
|
||||
selectNewNode: (nodeObj: NodeObj) => void
|
||||
selectNodes: (nodeObj: NodeObj[]) => void
|
||||
unselectNodes: (nodeObj: NodeObj[]) => void
|
||||
expandNode: (nodeObj: NodeObj) => void
|
||||
linkDiv: () => void
|
||||
scale: (scale: number) => void
|
||||
move: (data: { dx: number; dy: number }) => void
|
||||
/**
|
||||
* please use throttling to prevent performance degradation
|
||||
*/
|
||||
updateArrowDelta: (arrow: Arrow) => void
|
||||
showContextMenu: (e: MouseEvent) => void
|
||||
}
|
||||
|
||||
export function createBus<T extends Record<string, (...args: any[]) => void> = EventMap>() {
|
||||
return {
|
||||
handlers: {} as Record<keyof T, ((...arg: any[]) => void)[]>,
|
||||
addListener: function <K extends keyof T>(type: K, handler: T[K]) {
|
||||
if (this.handlers[type] === undefined) this.handlers[type] = []
|
||||
this.handlers[type].push(handler)
|
||||
},
|
||||
fire: function <K extends keyof T>(type: K, ...payload: Parameters<T[K]>) {
|
||||
if (this.handlers[type] instanceof Array) {
|
||||
const handlers = this.handlers[type]
|
||||
for (let i = 0; i < handlers.length; i++) {
|
||||
handlers[i](...payload)
|
||||
}
|
||||
}
|
||||
},
|
||||
removeListener: function <K extends keyof T>(type: K, handler: T[K]) {
|
||||
if (!this.handlers[type]) return
|
||||
const handlers = this.handlers[type]
|
||||
if (!handler) {
|
||||
handlers.length = 0
|
||||
} else if (handlers.length) {
|
||||
for (let i = 0; i < handlers.length; i++) {
|
||||
if (handlers[i] === handler) {
|
||||
this.handlers[type].splice(i, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
import { setAttributes } from '.'
|
||||
import type { Arrow } from '../arrow'
|
||||
import type { Summary } from '../summary'
|
||||
import type { MindElixirInstance } from '../types'
|
||||
import type { CustomSvg } from '../types/dom'
|
||||
import { selectText } from './dom'
|
||||
|
||||
const $d = document
|
||||
export const svgNS = 'http://www.w3.org/2000/svg'
|
||||
|
||||
export interface SvgTextOptions {
|
||||
anchor?: 'start' | 'middle' | 'end'
|
||||
color?: string
|
||||
dataType?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SVG text element with common attributes
|
||||
*/
|
||||
export const createSvgText = function (text: string, x: number, y: number, options: SvgTextOptions = {}): SVGTextElement {
|
||||
const { anchor = 'middle', color, dataType } = options
|
||||
|
||||
const textElement = document.createElementNS(svgNS, 'text')
|
||||
setAttributes(textElement, {
|
||||
'text-anchor': anchor,
|
||||
x: x + '',
|
||||
y: y + '',
|
||||
fill: color || (anchor === 'middle' ? 'rgb(235, 95, 82)' : '#666'),
|
||||
})
|
||||
|
||||
if (dataType) {
|
||||
textElement.dataset.type = dataType
|
||||
}
|
||||
|
||||
textElement.innerHTML = text
|
||||
return textElement
|
||||
}
|
||||
|
||||
export const createPath = function (d: string, color: string, width: string) {
|
||||
const path = $d.createElementNS(svgNS, 'path')
|
||||
setAttributes(path, {
|
||||
d,
|
||||
stroke: color || '#666',
|
||||
fill: 'none',
|
||||
'stroke-width': width,
|
||||
})
|
||||
return path
|
||||
}
|
||||
|
||||
export const createLinkSvg = function (klass: string) {
|
||||
const svg = $d.createElementNS(svgNS, 'svg')
|
||||
svg.setAttribute('class', klass)
|
||||
svg.setAttribute('overflow', 'visible')
|
||||
return svg
|
||||
}
|
||||
|
||||
export const createLine = function () {
|
||||
const line = $d.createElementNS(svgNS, 'line')
|
||||
line.setAttribute('stroke', '#4dc4ff')
|
||||
line.setAttribute('fill', 'none')
|
||||
line.setAttribute('stroke-width', '2')
|
||||
line.setAttribute('opacity', '0.45')
|
||||
return line
|
||||
}
|
||||
|
||||
export const createSvgGroup = function (
|
||||
d: string,
|
||||
arrowd1: string,
|
||||
arrowd2: string,
|
||||
style?: {
|
||||
stroke?: string
|
||||
strokeWidth?: string | number
|
||||
strokeDasharray?: string
|
||||
strokeLinecap?: 'butt' | 'round' | 'square'
|
||||
opacity?: string | number
|
||||
labelColor?: string
|
||||
}
|
||||
): CustomSvg {
|
||||
const g = $d.createElementNS(svgNS, 'g') as CustomSvg
|
||||
const svgs = [
|
||||
{
|
||||
name: 'line',
|
||||
d,
|
||||
},
|
||||
{
|
||||
name: 'arrow1',
|
||||
d: arrowd1,
|
||||
},
|
||||
{
|
||||
name: 'arrow2',
|
||||
d: arrowd2,
|
||||
},
|
||||
] as const
|
||||
svgs.forEach((item, i) => {
|
||||
const d = item.d
|
||||
const path = $d.createElementNS(svgNS, 'path')
|
||||
const attrs: { [key: string]: string } = {
|
||||
d,
|
||||
stroke: style?.stroke || 'rgb(235, 95, 82)',
|
||||
fill: 'none',
|
||||
'stroke-linecap': style?.strokeLinecap || 'cap',
|
||||
'stroke-width': String(style?.strokeWidth || '2'),
|
||||
}
|
||||
|
||||
if (style?.opacity !== undefined) {
|
||||
attrs['opacity'] = String(style.opacity)
|
||||
}
|
||||
|
||||
setAttributes(path, attrs)
|
||||
|
||||
if (i === 0) {
|
||||
// Apply stroke-dasharray to the main line
|
||||
path.setAttribute('stroke-dasharray', style?.strokeDasharray || '8,2')
|
||||
}
|
||||
|
||||
const hotzone = $d.createElementNS(svgNS, 'path')
|
||||
const hotzoneAttrs = {
|
||||
d,
|
||||
stroke: 'transparent',
|
||||
fill: 'none',
|
||||
'stroke-width': '15',
|
||||
}
|
||||
setAttributes(hotzone, hotzoneAttrs)
|
||||
g.appendChild(hotzone)
|
||||
|
||||
g.appendChild(path)
|
||||
g[item.name] = path
|
||||
})
|
||||
return g
|
||||
}
|
||||
|
||||
export const editSvgText = function (mei: MindElixirInstance, textEl: SVGTextElement, node: Summary | Arrow) {
|
||||
console.time('editSummary')
|
||||
if (!textEl) return
|
||||
const div = $d.createElement('div')
|
||||
mei.nodes.appendChild(div)
|
||||
const origin = textEl.innerHTML
|
||||
div.id = 'input-box'
|
||||
div.textContent = origin
|
||||
div.contentEditable = 'plaintext-only'
|
||||
div.spellcheck = false
|
||||
const bbox = textEl.getBBox()
|
||||
console.log(bbox)
|
||||
div.style.cssText = `
|
||||
min-width:${Math.max(88, bbox.width)}px;
|
||||
position:absolute;
|
||||
left:${bbox.x}px;
|
||||
top:${bbox.y}px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
`
|
||||
selectText(div)
|
||||
mei.scrollIntoView(div)
|
||||
|
||||
div.addEventListener('keydown', e => {
|
||||
e.stopPropagation()
|
||||
const key = e.key
|
||||
|
||||
if (key === 'Enter' || key === 'Tab') {
|
||||
// keep wrap for shift enter
|
||||
if (e.shiftKey) return
|
||||
|
||||
e.preventDefault()
|
||||
div.blur()
|
||||
mei.container.focus()
|
||||
}
|
||||
})
|
||||
div.addEventListener('blur', () => {
|
||||
if (!div) return
|
||||
const text = div.textContent?.trim() || ''
|
||||
if (text === '') node.label = origin
|
||||
else node.label = text
|
||||
div.remove()
|
||||
if (text === origin) return
|
||||
textEl.innerHTML = node.label
|
||||
|
||||
if ('parent' in node) {
|
||||
mei.bus.fire('operation', {
|
||||
name: 'finishEditSummary',
|
||||
obj: node,
|
||||
})
|
||||
} else {
|
||||
mei.bus.fire('operation', {
|
||||
name: 'finishEditArrowLabel',
|
||||
obj: node,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { DARK_THEME, THEME } from '../const'
|
||||
import type { MindElixirInstance } from '../types/index'
|
||||
import type { Theme } from '../types/index'
|
||||
|
||||
export const changeTheme = function (this: MindElixirInstance, theme: Theme, shouldRefresh = true) {
|
||||
this.theme = theme
|
||||
const base = theme.type === 'dark' ? DARK_THEME : THEME
|
||||
const cssVar = {
|
||||
...base.cssVar,
|
||||
...theme.cssVar,
|
||||
}
|
||||
const keys = Object.keys(cssVar)
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i] as keyof typeof cssVar
|
||||
this.container.style.setProperty(key, cssVar[key] as string)
|
||||
}
|
||||
shouldRefresh && this.refresh()
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { type Page, type Locator, expect } from '@playwright/test'
|
||||
import type { MindElixirCtor, MindElixirData, MindElixirInstance, Options } from '../src'
|
||||
import type MindElixir from '../src'
|
||||
interface Window {
|
||||
m: MindElixirInstance
|
||||
MindElixir: MindElixirCtor
|
||||
E: typeof MindElixir.E
|
||||
}
|
||||
declare let window: Window
|
||||
|
||||
export class MindElixirFixture {
|
||||
private m: MindElixirInstance
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
//
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('http://localhost:23334/test.html')
|
||||
}
|
||||
async init(data: MindElixirData, el = '#map') {
|
||||
// evaluate return Serializable value
|
||||
await this.page.evaluate(
|
||||
({ data, el }) => {
|
||||
const MindElixir = window.MindElixir
|
||||
const options: Options = {
|
||||
el,
|
||||
direction: MindElixir.SIDE,
|
||||
allowUndo: true, // Enable undo/redo functionality for tests
|
||||
keypress: true, // Enable keyboard shortcuts
|
||||
editable: true, // Enable editing
|
||||
}
|
||||
const mind = new MindElixir(options)
|
||||
mind.init(JSON.parse(JSON.stringify(data)))
|
||||
window[el] = mind
|
||||
return mind
|
||||
},
|
||||
{ data, el }
|
||||
)
|
||||
}
|
||||
async getInstance(el = '#map') {
|
||||
const instanceHandle = await this.page.evaluateHandle(el => Promise.resolve(window[el] as MindElixirInstance), el)
|
||||
return instanceHandle
|
||||
}
|
||||
async getData(el = '#map') {
|
||||
const data = await this.page.evaluate(el => {
|
||||
return window[el].getData()
|
||||
}, el)
|
||||
// console.log(a)
|
||||
// const dataHandle = await this.page.evaluateHandle(() => Promise.resolve(window.m.getData()))
|
||||
// const data = await dataHandle.jsonValue()
|
||||
return data
|
||||
}
|
||||
async dblclick(topic: string) {
|
||||
await this.page.getByText(topic, { exact: true }).dblclick({
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
async click(topic: string) {
|
||||
await this.page.getByText(topic, { exact: true }).click({
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
getByText(topic: string) {
|
||||
return this.page.getByText(topic, { exact: true })
|
||||
}
|
||||
async dragOver(topic: string, type: 'before' | 'after' | 'in') {
|
||||
await this.page.getByText(topic).hover({ force: true })
|
||||
await this.page.mouse.down()
|
||||
const target = await this.page.getByText(topic)
|
||||
const box = (await target.boundingBox())!
|
||||
const y = type === 'before' ? -12 : type === 'after' ? box.height + 12 : box.height / 2
|
||||
// https://playwright.dev/docs/input#dragging-manually
|
||||
// If your page relies on the dragover event being dispatched, you need at least two mouse moves to trigger it in all browsers.
|
||||
await this.page.mouse.move(box.x + box.width / 2, box.y + y)
|
||||
await this.page.waitForTimeout(100) // throttle
|
||||
await this.page.mouse.move(box.x + box.width / 2, box.y + y)
|
||||
}
|
||||
async dragSelect(topic1: string, topic2: string) {
|
||||
// Get the bounding boxes for both topics
|
||||
const element1 = this.page.getByText(topic1, { exact: true })
|
||||
const element2 = this.page.getByText(topic2, { exact: true })
|
||||
|
||||
const box1 = await element1.boundingBox()
|
||||
const box2 = await element2.boundingBox()
|
||||
|
||||
if (!box1 || !box2) {
|
||||
throw new Error(`Could not find bounding box for topics: ${topic1}, ${topic2}`)
|
||||
}
|
||||
|
||||
// Calculate the selection area coordinates
|
||||
// Find the minimum and maximum x, y coordinates
|
||||
const minX = Math.min(box1.x, box2.x) - 10
|
||||
const minY = Math.min(box1.y, box2.y) - 10
|
||||
const maxX = Math.max(box1.x + box1.width, box2.x + box2.width) + 10
|
||||
const maxY = Math.max(box1.y + box1.height, box2.y + box2.height) + 10
|
||||
|
||||
// Perform the drag selection
|
||||
await this.page.mouse.move(minX, minY)
|
||||
await this.page.mouse.down()
|
||||
await this.page.waitForTimeout(100) // throttle
|
||||
await this.page.mouse.move(maxX, maxY)
|
||||
await this.page.mouse.up()
|
||||
}
|
||||
async toHaveScreenshot(locator?: Locator) {
|
||||
await expect(locator || this.page.locator('me-nodes')).toHaveScreenshot({
|
||||
maxDiffPixelRatio: 0.02,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,729 +0,0 @@
|
|||
import { test, expect } from './mind-elixir-test'
|
||||
|
||||
const data = {
|
||||
nodeData: {
|
||||
topic: 'Root Topic',
|
||||
id: 'root',
|
||||
children: [
|
||||
{
|
||||
id: 'left-main',
|
||||
topic: 'Left Main',
|
||||
children: [
|
||||
{
|
||||
id: 'left-child-1',
|
||||
topic: 'Left Child 1',
|
||||
},
|
||||
{
|
||||
id: 'left-child-2',
|
||||
topic: 'Left Child 2',
|
||||
},
|
||||
{
|
||||
id: 'left-child-3',
|
||||
topic: 'Left Child 3',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'right-main',
|
||||
topic: 'Right Main',
|
||||
children: [
|
||||
{
|
||||
id: 'right-child-1',
|
||||
topic: 'Right Child 1',
|
||||
},
|
||||
{
|
||||
id: 'right-child-2',
|
||||
topic: 'Right Child 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ me }) => {
|
||||
await me.init(data)
|
||||
})
|
||||
|
||||
test('Create arrow between two nodes', async ({ page, me }) => {
|
||||
// Get the MindElixir instance and create arrow programmatically
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
|
||||
// Create arrow between two nodes
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow SVG group appears
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Verify arrow path is visible
|
||||
await expect(page.locator('svg g[data-linkid] path').first()).toBeVisible()
|
||||
|
||||
// Verify arrow head is visible
|
||||
await expect(page.locator('svg g[data-linkid] path').nth(1)).toBeVisible()
|
||||
|
||||
// Verify arrow label is visible
|
||||
await expect(page.locator('svg g[data-linkid] text')).toBeVisible()
|
||||
await expect(page.locator('svg g[data-linkid] text')).toHaveText('Custom Link')
|
||||
})
|
||||
|
||||
test('Create arrow with custom options', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
|
||||
// Create arrow with custom style options
|
||||
instance.createArrow(leftChild1, rightChild1, {
|
||||
bidirectional: true,
|
||||
style: {
|
||||
stroke: '#ff0000',
|
||||
strokeWidth: '3',
|
||||
strokeDasharray: '5,5',
|
||||
labelColor: '#0000ff',
|
||||
},
|
||||
})
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow appears
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Verify arrow appears with bidirectional option
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Verify multiple paths exist for bidirectional arrow (includes hotzone and highlight paths)
|
||||
const pathCount = await page.locator('svg g[data-linkid] path').count()
|
||||
expect(pathCount).toBeGreaterThan(3) // Should have more than 3 paths for bidirectional
|
||||
|
||||
// Verify custom label color
|
||||
const arrowLabel = page.locator('svg g[data-linkid] text')
|
||||
await expect(arrowLabel).toHaveAttribute('fill', '#0000ff')
|
||||
})
|
||||
|
||||
test('Create arrow from arrow object', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
await page.evaluate(async instance => {
|
||||
// Create arrow from arrow object
|
||||
instance.createArrowFrom({
|
||||
label: 'Test Arrow',
|
||||
from: 'left-child-1',
|
||||
to: 'right-child-1',
|
||||
delta1: { x: 50, y: 20 },
|
||||
delta2: { x: -50, y: -20 },
|
||||
style: {
|
||||
stroke: '#00ff00',
|
||||
strokeWidth: '2',
|
||||
},
|
||||
})
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow appears
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Verify custom label
|
||||
await expect(page.locator('svg g[data-linkid] text')).toHaveText('Test Arrow')
|
||||
|
||||
// Verify arrow appears with custom properties
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Verify at least one path has the custom style (may be applied to different elements)
|
||||
const hasCustomStroke = await page.evaluate(() => {
|
||||
const paths = document.querySelectorAll('svg g[data-linkid] path')
|
||||
return Array.from(paths).some(path => path.getAttribute('stroke') === '#00ff00' || path.getAttribute('stroke-width') === '2')
|
||||
})
|
||||
expect(hasCustomStroke).toBe(true)
|
||||
})
|
||||
|
||||
test('Select and highlight arrow', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create arrow first
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Click on the arrow to select it
|
||||
await page.locator('svg g[data-linkid]').click()
|
||||
|
||||
// Verify highlight appears (highlight group with higher opacity)
|
||||
await expect(page.locator('svg g[data-linkid] .arrow-highlight')).toBeVisible()
|
||||
|
||||
// Verify control points appear (they are div elements with class 'circle')
|
||||
await expect(page.locator('.circle').first()).toBeVisible()
|
||||
await expect(page.locator('.circle').last()).toBeVisible()
|
||||
|
||||
// Verify link controller appears
|
||||
await expect(page.locator('.linkcontroller')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Remove arrow', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create arrow first
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow exists
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Remove arrow programmatically
|
||||
await page.evaluate(async instance => {
|
||||
instance.removeArrow()
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow is removed
|
||||
await expect(page.locator('svg g[data-linkid]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Edit arrow label', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create arrow first
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Double click on arrow label to edit
|
||||
await page.locator('svg g[data-linkid] text').dblclick()
|
||||
|
||||
// Verify input box appears
|
||||
await expect(page.locator('#input-box')).toBeVisible()
|
||||
|
||||
// Type new label
|
||||
await page.keyboard.press('Control+a')
|
||||
await page.keyboard.insertText('Updated Arrow Label')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Verify input box disappears
|
||||
await expect(page.locator('#input-box')).toBeHidden()
|
||||
|
||||
// Verify new label is displayed
|
||||
await expect(page.locator('svg g[data-linkid] text')).toHaveText('Updated Arrow Label')
|
||||
})
|
||||
|
||||
test('Unselect arrow', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create arrow first
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Select arrow
|
||||
await page.locator('svg g[data-linkid]').click()
|
||||
await expect(page.locator('svg g[data-linkid] .arrow-highlight')).toBeVisible()
|
||||
|
||||
// Unselect arrow programmatically
|
||||
await page.evaluate(async instance => {
|
||||
instance.unselectArrow()
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify highlight disappears
|
||||
await expect(page.locator('svg g[data-linkid] .arrow-highlight')).not.toBeVisible()
|
||||
|
||||
// Verify control points disappear
|
||||
await expect(page.locator('.circle').first()).not.toBeVisible()
|
||||
await expect(page.locator('.circle').last()).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Render multiple arrows', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create multiple arrows
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const leftChild2 = instance.findEle('left-child-2')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
const rightChild2 = instance.findEle('right-child-2')
|
||||
|
||||
// Create first arrow
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
|
||||
// Create second arrow
|
||||
instance.createArrow(leftChild2, rightChild2)
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify both arrows exist
|
||||
await expect(page.locator('svg g[data-linkid]')).toHaveCount(2)
|
||||
|
||||
// Verify both have labels
|
||||
await expect(page.locator('svg g[data-linkid] text')).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Arrow positioning and bezier curve', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Get arrow path element
|
||||
const arrowPath = page.locator('svg g[data-linkid] path').first()
|
||||
|
||||
// Verify path has bezier curve (should contain 'C' command)
|
||||
const pathData = await arrowPath.getAttribute('d')
|
||||
expect(pathData).toContain('M') // Move to start point
|
||||
expect(pathData).toContain('C') // Cubic bezier curve
|
||||
|
||||
// Verify arrow label is positioned at curve midpoint
|
||||
const arrowLabel = page.locator('svg g[data-linkid] text')
|
||||
await expect(arrowLabel).toBeVisible()
|
||||
|
||||
// Label should have x and y coordinates
|
||||
const labelX = await arrowLabel.getAttribute('x')
|
||||
const labelY = await arrowLabel.getAttribute('y')
|
||||
expect(labelX).toBeTruthy()
|
||||
expect(labelY).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Arrow style inheritance and defaults', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
|
||||
// Create arrow without custom styles
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify default styles are applied
|
||||
const arrowPath = page.locator('svg g[data-linkid] path').first()
|
||||
|
||||
// Check default stroke attributes exist
|
||||
const stroke = await arrowPath.getAttribute('stroke')
|
||||
const strokeWidth = await arrowPath.getAttribute('stroke-width')
|
||||
const fill = await arrowPath.getAttribute('fill')
|
||||
|
||||
expect(stroke).toBeTruthy()
|
||||
expect(strokeWidth).toBeTruthy()
|
||||
expect(fill).toBe('none') // Arrows should not be filled
|
||||
|
||||
// Verify default label color
|
||||
const arrowLabel = page.locator('svg g[data-linkid] text')
|
||||
const labelFill = await arrowLabel.getAttribute('fill')
|
||||
expect(labelFill).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Arrow with opacity style', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
|
||||
// Create arrow with opacity
|
||||
instance.createArrow(leftChild1, rightChild1, {
|
||||
style: {
|
||||
opacity: '0.5',
|
||||
},
|
||||
})
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow appears with opacity style
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Verify at least one path has opacity applied
|
||||
const hasOpacity = await page.evaluate(() => {
|
||||
const paths = document.querySelectorAll('svg g[data-linkid] path')
|
||||
return Array.from(paths).some(path => path.getAttribute('opacity') === '0.5')
|
||||
})
|
||||
expect(hasOpacity).toBe(true)
|
||||
})
|
||||
|
||||
test('Bidirectional arrow rendering', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
|
||||
// Create bidirectional arrow
|
||||
instance.createArrow(leftChild1, rightChild1, {
|
||||
bidirectional: true,
|
||||
})
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify bidirectional arrow appears with multiple paths
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Verify multiple paths exist (should be more than a simple arrow)
|
||||
const pathCount = await page.locator('svg g[data-linkid] path').count()
|
||||
expect(pathCount).toBeGreaterThan(2) // Should have more paths for bidirectional
|
||||
|
||||
// Verify paths have basic stroke attributes
|
||||
const paths = page.locator('svg g[data-linkid] path')
|
||||
const firstPath = paths.first()
|
||||
await expect(firstPath).toHaveAttribute('fill', 'none')
|
||||
})
|
||||
|
||||
test('Arrow control point manipulation', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create arrow first
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Select arrow to show control points
|
||||
await page.locator('svg g[data-linkid]').click()
|
||||
|
||||
// Verify control points are visible
|
||||
const p2Element = page.locator('.circle').first()
|
||||
const p3Element = page.locator('.circle').last()
|
||||
await expect(p2Element).toBeVisible()
|
||||
await expect(p3Element).toBeVisible()
|
||||
|
||||
// Get initial positions
|
||||
const p2InitialBox = await p2Element.boundingBox()
|
||||
|
||||
// Drag P2 control point
|
||||
await p2Element.hover()
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(p2InitialBox!.x + 50, p2InitialBox!.y + 30)
|
||||
await page.mouse.up()
|
||||
|
||||
// Verify control point moved
|
||||
const p2NewBox = await p2Element.boundingBox()
|
||||
expect(Math.abs(p2NewBox!.x - (p2InitialBox!.x + 50))).toBeLessThan(10)
|
||||
expect(Math.abs(p2NewBox!.y - (p2InitialBox!.y + 30))).toBeLessThan(10)
|
||||
})
|
||||
|
||||
test('Arrow deletion via keyboard', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create arrow first
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Select arrow
|
||||
await page.locator('svg g[data-linkid]').click()
|
||||
await expect(page.locator('svg g[data-linkid] .arrow-highlight')).toBeVisible()
|
||||
|
||||
// Delete arrow using keyboard
|
||||
await page.keyboard.press('Delete')
|
||||
|
||||
// Verify arrow is removed
|
||||
await expect(page.locator('svg g[data-linkid]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Arrow with stroke linecap styles', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
|
||||
// Create arrow with round linecap
|
||||
instance.createArrow(leftChild1, rightChild1, {
|
||||
style: {
|
||||
strokeLinecap: 'round',
|
||||
},
|
||||
})
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow appears with linecap style
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Verify at least one path has the linecap style
|
||||
const hasLinecap = await page.evaluate(() => {
|
||||
const paths = document.querySelectorAll('svg g[data-linkid] path')
|
||||
return Array.from(paths).some(path => path.getAttribute('stroke-linecap') === 'round')
|
||||
})
|
||||
expect(hasLinecap).toBe(true)
|
||||
})
|
||||
|
||||
test('Arrow label text anchor positioning', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow label has middle text anchor (centered)
|
||||
const arrowLabel = page.locator('svg g[data-linkid] text')
|
||||
await expect(arrowLabel).toHaveAttribute('text-anchor', 'middle')
|
||||
|
||||
// Verify label has custom-link data type
|
||||
await expect(arrowLabel).toHaveAttribute('data-type', 'custom-link')
|
||||
})
|
||||
|
||||
test('Arrow rendering after node expansion/collapse', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create arrow between child nodes
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow exists
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Collapse left main node by clicking its expander
|
||||
const leftMainExpander = page.locator('me-tpc[data-nodeid="meleft-main"] me-expander')
|
||||
if (await leftMainExpander.isVisible()) {
|
||||
await leftMainExpander.click()
|
||||
}
|
||||
|
||||
// Arrow should still exist but may not be visible due to collapsed nodes
|
||||
// This tests the robustness of arrow rendering
|
||||
|
||||
// Expand left main node again
|
||||
if (await leftMainExpander.isVisible()) {
|
||||
await leftMainExpander.click()
|
||||
}
|
||||
|
||||
// Re-render arrows
|
||||
await page.evaluate(async instance => {
|
||||
instance.renderArrow()
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow is visible again
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Multiple arrow selection state management', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create two arrows
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const leftChild2 = instance.findEle('left-child-2')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
const rightChild2 = instance.findEle('right-child-2')
|
||||
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
instance.createArrow(leftChild2, rightChild2)
|
||||
}, instanceHandle)
|
||||
|
||||
const arrows = page.locator('svg g[data-linkid]')
|
||||
const firstArrow = arrows.first()
|
||||
const secondArrow = arrows.last()
|
||||
|
||||
// Select first arrow
|
||||
await firstArrow.click()
|
||||
await expect(page.locator('.arrow-highlight').first()).toBeVisible()
|
||||
|
||||
// Select second arrow
|
||||
await secondArrow.click()
|
||||
await expect(page.locator('.arrow-highlight').first()).toBeVisible()
|
||||
|
||||
// Click elsewhere to deselect
|
||||
await page.locator('#map').click()
|
||||
|
||||
// Wait a bit for deselection to take effect
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
// Verify that selection state has changed (may still have some highlights due to timing)
|
||||
// The important thing is that the selection behavior works
|
||||
const arrowsExist = await page.locator('svg g[data-linkid]').count()
|
||||
expect(arrowsExist).toBe(2) // Both arrows should still exist
|
||||
})
|
||||
|
||||
test('Arrow data persistence and retrieval', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create arrow with specific properties
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
|
||||
instance.createArrow(leftChild1, rightChild1, {
|
||||
bidirectional: true,
|
||||
style: {
|
||||
stroke: '#ff6600',
|
||||
strokeWidth: '4',
|
||||
labelColor: '#333333',
|
||||
},
|
||||
})
|
||||
}, instanceHandle)
|
||||
|
||||
// Get arrow data from instance
|
||||
const arrowData = await page.evaluate(async instance => {
|
||||
return instance.arrows[0]
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow properties are correctly stored
|
||||
expect(arrowData.label).toBe('Custom Link')
|
||||
expect(arrowData.from).toBe('left-child-1')
|
||||
expect(arrowData.to).toBe('right-child-1')
|
||||
expect(arrowData.bidirectional).toBe(true)
|
||||
expect(arrowData.style?.stroke).toBe('#ff6600')
|
||||
expect(arrowData.style?.strokeWidth).toBe('4')
|
||||
expect(arrowData.style?.labelColor).toBe('#333333')
|
||||
expect(arrowData.id).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Arrow tidy function removes invalid arrows', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create arrow first
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow exists
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Simulate removing a node that the arrow references
|
||||
await page.evaluate(async instance => {
|
||||
// Manually corrupt arrow data to simulate invalid reference
|
||||
instance.arrows[0].to = 'non-existent-node'
|
||||
|
||||
// Run tidy function
|
||||
instance.tidyArrow()
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow was removed by tidy function
|
||||
const arrowCount = await page.evaluate(async instance => {
|
||||
return instance.arrows.length
|
||||
}, instanceHandle)
|
||||
|
||||
expect(arrowCount).toBe(0)
|
||||
})
|
||||
|
||||
test('Arrow highlight update during control point drag', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Create arrow first
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Select arrow to show control points
|
||||
await page.locator('svg g[data-linkid]').click()
|
||||
|
||||
// Verify highlight is visible
|
||||
await expect(page.locator('svg g[data-linkid] .arrow-highlight')).toBeVisible()
|
||||
|
||||
// Get initial highlight path
|
||||
const initialHighlightPath = await page.locator('svg g[data-linkid] .arrow-highlight path').first().getAttribute('d')
|
||||
|
||||
// Drag control point to change arrow shape
|
||||
const p2Element = page.locator('.circle').first()
|
||||
const p2Box = await p2Element.boundingBox()
|
||||
await p2Element.hover()
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(p2Box!.x + 100, p2Box!.y + 50)
|
||||
await page.mouse.up()
|
||||
|
||||
// Verify highlight path updated
|
||||
const updatedHighlightPath = await page.locator('svg g[data-linkid] .arrow-highlight path').first().getAttribute('d')
|
||||
expect(updatedHighlightPath).not.toBe(initialHighlightPath)
|
||||
})
|
||||
|
||||
test('Arrow creation with invalid nodes', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
// Try to create arrow with undefined nodes (simulating collapsed/hidden nodes)
|
||||
await page.evaluate(async instance => {
|
||||
try {
|
||||
// Simulate trying to create arrow when nodes are not found
|
||||
const nonExistentNode1 = instance.findEle('non-existent-1')
|
||||
const nonExistentNode2 = instance.findEle('non-existent-2')
|
||||
|
||||
// This should not create an arrow since nodes don't exist
|
||||
if (nonExistentNode1 && nonExistentNode2) {
|
||||
instance.createArrow(nonExistentNode1, nonExistentNode2)
|
||||
}
|
||||
} catch (error) {
|
||||
// Expected to fail gracefully
|
||||
console.log('Arrow creation failed as expected:', error.message)
|
||||
}
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify no arrow was created
|
||||
await expect(page.locator('svg g[data-linkid]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Arrow bezier midpoint calculation', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
instance.createArrow(leftChild1, rightChild1)
|
||||
}, instanceHandle)
|
||||
|
||||
// Get arrow label position
|
||||
const labelX = await page.locator('svg g[data-linkid] text').getAttribute('x')
|
||||
const labelY = await page.locator('svg g[data-linkid] text').getAttribute('y')
|
||||
|
||||
// Verify label is positioned (should have numeric coordinates)
|
||||
expect(parseFloat(labelX!)).toBeGreaterThan(0)
|
||||
expect(parseFloat(labelY!)).toBeGreaterThan(0)
|
||||
|
||||
// Get arrow path to verify label is positioned along the curve
|
||||
const pathData = await page.locator('svg g[data-linkid] path').first().getAttribute('d')
|
||||
expect(pathData).toContain('M') // Move command
|
||||
expect(pathData).toContain('C') // Cubic bezier command
|
||||
})
|
||||
|
||||
test('Arrow style application to all elements', async ({ page, me }) => {
|
||||
const instanceHandle = await me.getInstance()
|
||||
|
||||
await page.evaluate(async instance => {
|
||||
const leftChild1 = instance.findEle('left-child-1')
|
||||
const rightChild1 = instance.findEle('right-child-1')
|
||||
|
||||
// Create bidirectional arrow with comprehensive styles
|
||||
instance.createArrow(leftChild1, rightChild1, {
|
||||
bidirectional: true,
|
||||
style: {
|
||||
stroke: '#purple',
|
||||
strokeWidth: '5',
|
||||
strokeDasharray: '10,5',
|
||||
strokeLinecap: 'square',
|
||||
opacity: '0.8',
|
||||
labelColor: '#orange',
|
||||
},
|
||||
})
|
||||
}, instanceHandle)
|
||||
|
||||
// Verify arrow appears with comprehensive styles
|
||||
await expect(page.locator('svg g[data-linkid]')).toBeVisible()
|
||||
|
||||
// Verify styles are applied to arrow elements
|
||||
const hasStyles = await page.evaluate(() => {
|
||||
const paths = document.querySelectorAll('svg g[data-linkid] path')
|
||||
const hasStroke = Array.from(paths).some(path => path.getAttribute('stroke') === '#purple')
|
||||
const hasWidth = Array.from(paths).some(path => path.getAttribute('stroke-width') === '5')
|
||||
const hasOpacity = Array.from(paths).some(path => path.getAttribute('opacity') === '0.8')
|
||||
return hasStroke && hasWidth && hasOpacity
|
||||
})
|
||||
expect(hasStyles).toBe(true)
|
||||
|
||||
// Verify label color
|
||||
const label = page.locator('svg g[data-linkid] text')
|
||||
await expect(label).toHaveAttribute('fill', '#orange')
|
||||
})
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { test, expect } from './mind-elixir-test'
|
||||
|
||||
const m1 = 'm1'
|
||||
const m2 = 'm2'
|
||||
const childTopic = 'child-topic'
|
||||
const data = {
|
||||
nodeData: {
|
||||
topic: 'root-topic',
|
||||
id: 'root-id',
|
||||
children: [
|
||||
{
|
||||
id: m1,
|
||||
topic: m1,
|
||||
children: [
|
||||
{
|
||||
id: 'child',
|
||||
topic: childTopic,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: m2,
|
||||
topic: m2,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ me }) => {
|
||||
await me.init(data)
|
||||
})
|
||||
|
||||
test('DnD move before', async ({ page, me }) => {
|
||||
await page.getByText(m2).hover({ force: true })
|
||||
await page.mouse.down()
|
||||
await me.dragOver(m1, 'before')
|
||||
await expect(page.locator('.insert-preview.before')).toBeVisible()
|
||||
await page.mouse.up()
|
||||
await me.toHaveScreenshot()
|
||||
})
|
||||
|
||||
test('DnD move after', async ({ page, me }) => {
|
||||
await page.getByText(m2).hover({ force: true })
|
||||
await page.mouse.down()
|
||||
await me.dragOver(m1, 'after')
|
||||
await expect(page.locator('.insert-preview.after')).toBeVisible()
|
||||
await page.mouse.up()
|
||||
await me.toHaveScreenshot()
|
||||
})
|
||||
|
||||
test('DnD move in', async ({ page, me }) => {
|
||||
await page.getByText(m2).hover({ force: true })
|
||||
await page.mouse.down()
|
||||
await me.dragOver(m1, 'in')
|
||||
await expect(page.locator('.insert-preview.in')).toBeVisible()
|
||||
await page.mouse.up()
|
||||
await me.toHaveScreenshot()
|
||||
})
|
||||
|
Before Width: | Height: | Size: 13 KiB |