feat: 完善图片显示和性能优化

- 添加图片数据库字段支持
This commit is contained in:
lixinran 2025-10-09 16:02:23 +08:00
parent 3b39f86f83
commit c95bbd649b
128 changed files with 940 additions and 29140 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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/` - 项目中使用的增强版本
- **保留**: 核心测试文件 - 用于功能验证和问题调试
- **删除**: 重复的调试文件和过时的测试文件

Binary file not shown.

View File

@ -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:

View File

@ -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),
),
]

View File

@ -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

View File

@ -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": {
@ -50,6 +50,17 @@ def convert_to_mindelixir_format(mindmap, nodes):
"mindmapId": mindmap.id,
"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 = []
@ -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',
)
# 递归创建子节点

BIN
frontend/.DS_Store vendored

Binary file not shown.

File diff suppressed because one or more lines are too long

606
frontend/dist/assets/index-7802977e.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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>

View File

@ -292,7 +292,27 @@ const generateMarkdownFromFile = async () => {
markdownContent.value = '';
// AI APIMarkdown
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}`;
// 使APIAPI
@ -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 APIMarkdown
// 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,28 +624,57 @@ Level 4 标题用 #####
// Markdown
markdownContent.value += data.content;
// JSON
try {
const tempJSON = markdownToJSON(markdownContent.value);
convertedJSON.value = JSON.stringify(tempJSON, null, 2);
// 🎯
window.dispatchEvent(new CustomEvent('realtime-mindmap-update', {
detail: {
data: tempJSON,
title: tempJSON.topic || 'AI生成中...',
source: 'ai-streaming',
chunkCount: chunkCount
}
}));
} catch (e) {
//
console.warn('⚠️ 实时转换JSON失败:', e);
console.warn('⚠️ 当前Markdown内容:', markdownContent.value);
// 5chunk
if (chunkCount % 5 === 0) {
try {
// AI
const cleanedContent = cleanAIResponse(markdownContent.value);
const tempJSON = markdownToJSON(cleanedContent);
convertedJSON.value = JSON.stringify(tempJSON, null, 2);
// 🎯
window.dispatchEvent(new CustomEvent('realtime-mindmap-update', {
detail: {
data: tempJSON,
title: tempJSON.topic || 'AI生成中...',
source: 'ai-streaming',
chunkCount: chunkCount
}
}));
} catch (e) {
//
console.warn('⚠️ 实时转换JSON失败:', e);
}
}
} 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) => {
}
}
// MindElixirimage
return markdown
//
.replace(/^### (.*$)/gim, '📋 $1') //
@ -759,9 +873,22 @@ const formatMarkdownToText = (markdown) => {
.replace(/`(.*?)`/g, '「$1」')
//
.replace(/\[([^\]]+)\]\([^)]+\)/g, '🔗 $1')
//
// MindElixirimage
.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();
};
// MarkdownJSON
@ -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();
};
// MarkdownJSON -
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') {

View File

@ -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;
}
});

View File

@ -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)

View File

@ -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,

View File

@ -1,4 +0,0 @@
dist
src/__tests__
index.html
test.html

View File

@ -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',
},
}

View File

@ -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']

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit ${1}

View File

@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

View File

@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run test

View File

@ -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/

View File

@ -1,9 +0,0 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"arrowParens": "avoid",
"printWidth": 150,
"endOfLine": "auto"
}

View File

@ -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.

View File

@ -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
// },
//
// . . .
}
}
}

View File

@ -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,
})
}

View File

@ -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".)',
},
},
},
}

View File

@ -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;
}

View File

@ -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>

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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'
},
},
})

File diff suppressed because it is too large Load Diff

View File

@ -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
![mindelixir](https://raw.githubusercontent.com/ssshooter/mind-elixir-core/master/images/screenshot5.jpg)
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>

File diff suppressed because it is too large Load Diff

View File

@ -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)
})
}

View File

@ -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',
},
}

View File

@ -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)
})

View File

@ -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))

View File

@ -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'

View File

@ -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">&ZeroWidthSpace;</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">&ZeroWidthSpace;</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">&ZeroWidthSpace;</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">&ZeroWidthSpace;</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

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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">&ZeroWidthSpace;</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">&ZeroWidthSpace;</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">&ZeroWidthSpace;</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">&ZeroWidthSpace;</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>`

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;
}
}

View File

@ -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'

View File

@ -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()
}

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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()
}

View File

@ -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;
}

View File

@ -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
}
}

View File

@ -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
})
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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;
}
}

View File

@ -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))
}

View File

@ -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')
}

View File

@ -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
}

View File

@ -1,7 +0,0 @@
export {}
declare global {
interface Element {
setAttribute(name: string, value: boolean): void
setAttribute(name: string, value: number): void
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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()
}

View File

@ -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
},
}
}

View File

@ -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}`
}
}

View File

@ -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, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;')
}
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)
}
}
}

View File

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
/**
* 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(),
}
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}
},
}
}

View File

@ -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,
})
}
})
}

View File

@ -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()
}

View File

@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@ -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,
})
}
}

View File

@ -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')
})

View File

@ -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()
})

Some files were not shown because too many files have changed in this diff Show More