wendang 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +3 -0
- package/NOTICE.md +7 -0
- package/README.md +101 -2
- package/dist/cjs/document.js +19 -0
- package/dist/cjs/index.js +25 -0
- package/dist/cjs/mindmap.js +22 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/schema.js +11 -0
- package/dist/cjs/shared/anchor.js +201 -0
- package/dist/cjs/shared/callout.js +64 -0
- package/dist/cjs/shared/code-block.js +33 -0
- package/dist/cjs/shared/code-languages.js +51 -0
- package/dist/cjs/shared/doc-compatibility.js +23 -0
- package/dist/cjs/shared/doc-text.js +52 -0
- package/dist/cjs/shared/document-colors.js +61 -0
- package/dist/cjs/shared/inline-comments.js +179 -0
- package/dist/cjs/shared/mind-map-folding.js +14 -0
- package/dist/cjs/shared/mind-map-phase2.js +133 -0
- package/dist/cjs/shared/mind-map.js +445 -0
- package/dist/cjs/shared/mind-topic-text.js +57 -0
- package/dist/cjs/shared/node-catalog.js +62 -0
- package/dist/cjs/shared/presence.js +141 -0
- package/dist/cjs/shared/safe-href.js +36 -0
- package/dist/cjs/shared/table-cell-format.js +18 -0
- package/dist/cjs/shared/table-layout.js +28 -0
- package/dist/cjs/shared/types.js +2 -0
- package/dist/cjs/shared/video-embed.js +102 -0
- package/dist/cjs/shared/workbook.js +152 -0
- package/dist/cjs/spreadsheet.js +17 -0
- package/dist/cjs/types.js +17 -0
- package/dist/cjs/validation/callout.js +17 -0
- package/dist/cjs/validation/doc-node-whitelist.js +71 -0
- package/dist/cjs/validation/error.js +17 -0
- package/dist/cjs/validation/mind-map.js +129 -0
- package/dist/cjs/validation/schema-version.js +5 -0
- package/dist/cjs/validation/schema.js +376 -0
- package/dist/cjs/validation/video-embed.js +17 -0
- package/dist/cjs/validation/workbook.js +17 -0
- package/dist/esm/document.js +3 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/mindmap.js +4 -0
- package/dist/esm/schema.js +3 -0
- package/dist/esm/shared/anchor.js +192 -0
- package/dist/esm/shared/callout.js +56 -0
- package/dist/esm/shared/code-block.js +25 -0
- package/dist/esm/shared/code-languages.js +46 -0
- package/dist/esm/shared/doc-compatibility.js +20 -0
- package/dist/esm/shared/doc-text.js +48 -0
- package/dist/esm/shared/document-colors.js +56 -0
- package/dist/esm/shared/inline-comments.js +173 -0
- package/dist/esm/shared/mind-map-folding.js +10 -0
- package/dist/esm/shared/mind-map-phase2.js +121 -0
- package/dist/esm/shared/mind-map.js +427 -0
- package/dist/esm/shared/mind-topic-text.js +49 -0
- package/dist/esm/shared/node-catalog.js +57 -0
- package/dist/esm/shared/presence.js +130 -0
- package/dist/esm/shared/safe-href.js +32 -0
- package/dist/esm/shared/table-cell-format.js +14 -0
- package/dist/esm/shared/table-layout.js +23 -0
- package/dist/esm/shared/types.js +1 -0
- package/dist/esm/shared/video-embed.js +96 -0
- package/dist/esm/shared/workbook.js +144 -0
- package/dist/esm/spreadsheet.js +1 -0
- package/dist/esm/types.js +1 -0
- package/dist/esm/validation/callout.js +1 -0
- package/dist/esm/validation/doc-node-whitelist.js +68 -0
- package/dist/esm/validation/error.js +13 -0
- package/dist/esm/validation/mind-map.js +125 -0
- package/dist/esm/validation/schema-version.js +2 -0
- package/dist/esm/validation/schema.js +372 -0
- package/dist/esm/validation/video-embed.js +1 -0
- package/dist/esm/validation/workbook.js +1 -0
- package/dist/types/document.d.ts +4 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/mindmap.d.ts +4 -0
- package/dist/types/schema.d.ts +4 -0
- package/dist/types/shared/anchor.d.ts +87 -0
- package/dist/types/shared/callout.d.ts +27 -0
- package/dist/types/shared/code-block.d.ts +26 -0
- package/dist/types/shared/code-languages.d.ts +26 -0
- package/dist/types/shared/doc-compatibility.d.ts +1 -0
- package/dist/types/shared/doc-text.d.ts +21 -0
- package/dist/types/shared/document-colors.d.ts +106 -0
- package/dist/types/shared/inline-comments.d.ts +71 -0
- package/dist/types/shared/mind-map-folding.d.ts +5 -0
- package/dist/types/shared/mind-map-phase2.d.ts +19 -0
- package/dist/types/shared/mind-map.d.ts +134 -0
- package/dist/types/shared/mind-topic-text.d.ts +18 -0
- package/dist/types/shared/node-catalog.d.ts +30 -0
- package/dist/types/shared/presence.d.ts +69 -0
- package/dist/types/shared/safe-href.d.ts +3 -0
- package/dist/types/shared/table-cell-format.d.ts +24 -0
- package/dist/types/shared/table-layout.d.ts +7 -0
- package/dist/types/shared/types.d.ts +44 -0
- package/dist/types/shared/video-embed.d.ts +11 -0
- package/dist/types/shared/workbook.d.ts +56 -0
- package/dist/types/spreadsheet.d.ts +1 -0
- package/dist/types/types.d.ts +3 -0
- package/dist/types/validation/callout.d.ts +1 -0
- package/dist/types/validation/doc-node-whitelist.d.ts +21 -0
- package/dist/types/validation/error.d.ts +7 -0
- package/dist/types/validation/mind-map.d.ts +22 -0
- package/dist/types/validation/schema-version.d.ts +2 -0
- package/dist/types/validation/schema.d.ts +49 -0
- package/dist/types/validation/video-embed.d.ts +1 -0
- package/dist/types/validation/workbook.d.ts +1 -0
- package/package.json +87 -5
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createMindMap, mindMapDescendants, mindMapId, mindMapOutline, mindMapToJson, mindMapTree, readMindMap, MIND_MAP_VERSION, } from './mind-map.js';
|
|
2
|
+
export const MIND_ICONS = ['', '💡', '🎯', '📌', '🚩', '⭐', '✅', '⚠️', '💬', '📅', '🔎'];
|
|
3
|
+
export const MIND_COLORS = ['', '#18243a', '#ffffff', '#3864ef', '#087f8c', '#7950c7', '#a66b08', '#d94848'];
|
|
4
|
+
export const MIND_BACKGROUNDS = ['', '#f3f5fb', '#dce7ff', '#d3f4e9', '#ece0ff', '#fff0c2', '#ffe1e1', '#233044'];
|
|
5
|
+
export function topicTitle(node) {
|
|
6
|
+
return (node.content ?? []).filter(c => c.type === 'text').map(c => c.text ?? '').join('');
|
|
7
|
+
}
|
|
8
|
+
export function topicField(node, field) {
|
|
9
|
+
return (node.content?.find(c => c.type === (field === 'description' ? 'mindMapDescription' : 'mindMapNote'))?.content ?? [])
|
|
10
|
+
.map(c => c.text ?? '').join('');
|
|
11
|
+
}
|
|
12
|
+
export function branchModel(model, id) {
|
|
13
|
+
const ids = mindMapDescendants(model, id);
|
|
14
|
+
return { ...model, rootId: id, topics: mindMapTree(model).filter(t => ids.has(t.id))
|
|
15
|
+
.map(t => t.id === id ? { ...t, parentId: '' } : t),
|
|
16
|
+
relations: model.relations?.filter(r => ids.has(r.source) && ids.has(r.target)) };
|
|
17
|
+
}
|
|
18
|
+
export function ancestorIds(model, id) {
|
|
19
|
+
const byId = new Map(mindMapTree(model).map(t => [t.id, t])), result = [];
|
|
20
|
+
let current = byId.get(id);
|
|
21
|
+
while (current?.parentId && result.length < 500) {
|
|
22
|
+
result.push(current.parentId);
|
|
23
|
+
current = byId.get(current.parentId);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
export function searchMindMap(model, query, details = true) {
|
|
28
|
+
if (!query)
|
|
29
|
+
return [];
|
|
30
|
+
const needle = query.toLocaleLowerCase(), hits = [];
|
|
31
|
+
for (const topic of mindMapOutline(model)) {
|
|
32
|
+
for (const field of (details ? ['title', 'description', 'note'] : ['title'])) {
|
|
33
|
+
const value = (field === 'title' ? topic.text : topic[field] ?? '').toLocaleLowerCase();
|
|
34
|
+
for (let at = 0; (at = value.indexOf(needle, at)) >= 0; at += needle.length) {
|
|
35
|
+
hits.push({ id: topic.id, field, from: at, to: at + needle.length });
|
|
36
|
+
if (hits.length > 5000)
|
|
37
|
+
return hits;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return hits;
|
|
42
|
+
}
|
|
43
|
+
export function parseMindMapImport(raw) {
|
|
44
|
+
if (new TextEncoder().encode(raw).length > 2_000_000)
|
|
45
|
+
throw new Error('导入内容过大(最多 2 MB)');
|
|
46
|
+
if (raw.trimStart().startsWith('{')) {
|
|
47
|
+
const data = JSON.parse(raw);
|
|
48
|
+
const model = data.type === 'mindMap' ? readMindMap(data) :
|
|
49
|
+
data.format === 'ywiki-mind-map' && [1, 2, MIND_MAP_VERSION].includes(data.version) && Array.isArray(data.topics)
|
|
50
|
+
? readMindMap(mindMapToJson(data)) : null;
|
|
51
|
+
if (!model)
|
|
52
|
+
throw new Error('导图结构或版本无效');
|
|
53
|
+
if (model.topics.some(t => t.id !== model.rootId && !model.topics.some(p => p.id === t.parentId)))
|
|
54
|
+
throw new Error('导入结构包含不存在的父主题');
|
|
55
|
+
if (model.relations?.some(r => !model.topics.some(t => t.id === r.source) || !model.topics.some(t => t.id === r.target)))
|
|
56
|
+
throw new Error('导入关系的端点不存在');
|
|
57
|
+
for (const topic of model.topics) {
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
let current = topic;
|
|
60
|
+
while (current?.parentId) {
|
|
61
|
+
if (seen.has(current.id))
|
|
62
|
+
throw new Error('导入结构存在循环关系');
|
|
63
|
+
seen.add(current.id);
|
|
64
|
+
current = model.topics.find(t => t.id === current.parentId);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return readMindMap(mindMapToJson(model, true));
|
|
68
|
+
}
|
|
69
|
+
const lines = raw.replace(/\r/g, '').split('\n').filter(l => l.trim());
|
|
70
|
+
if (!lines.length)
|
|
71
|
+
throw new Error('请输入缩进列表或导图 JSON');
|
|
72
|
+
if (lines.length > 499)
|
|
73
|
+
throw new Error('导入列表最多 499 行,加中心主题后最多 500 个主题');
|
|
74
|
+
const model = readMindMap(createMindMap());
|
|
75
|
+
model.topics = [model.topics[0]];
|
|
76
|
+
model.topics[0].text = '导入的主题';
|
|
77
|
+
const stack = [{ indent: -1, id: model.rootId }];
|
|
78
|
+
for (let i = 0; i < lines.length; i++) {
|
|
79
|
+
const line = lines[i], white = line.match(/^\s*/)?.[0] ?? '', indent = white.replace(/\t/g, ' ').length;
|
|
80
|
+
const text = line.trim().replace(/^(?:[-*+]\s+|\d+[.)]\s+)/, '');
|
|
81
|
+
if (text.length > 2000)
|
|
82
|
+
throw new Error(`第 ${i + 1} 行超过 2000 字符`);
|
|
83
|
+
while (stack.length > 1 && stack.at(-1).indent >= indent)
|
|
84
|
+
stack.pop();
|
|
85
|
+
if (stack.length > 50)
|
|
86
|
+
throw new Error(`第 ${i + 1} 行嵌套超过 50 层`);
|
|
87
|
+
const id = mindMapId();
|
|
88
|
+
model.topics.push({ id, parentId: stack.at(-1).id, order: i, text });
|
|
89
|
+
stack.push({ indent, id });
|
|
90
|
+
}
|
|
91
|
+
return model;
|
|
92
|
+
}
|
|
93
|
+
export function mindMapTemplate(kind) {
|
|
94
|
+
const data = {
|
|
95
|
+
project: ['项目拆解', '目标与范围', '需求梳理', '研发实现', '测试验收', '上线与复盘'],
|
|
96
|
+
meeting: ['会议讨论', '议题', '背景信息', '讨论结论', '待办事项', '负责人'],
|
|
97
|
+
knowledge: ['知识目录', '基础概念', '核心原理', '实践案例', '常见问题', '参考资料'],
|
|
98
|
+
}[kind];
|
|
99
|
+
const m = readMindMap(createMindMap('both'));
|
|
100
|
+
m.topics = data.map((text, i) => ({ id: i ? mindMapId() : m.rootId, parentId: i ? m.rootId : '', order: i, text }));
|
|
101
|
+
return m;
|
|
102
|
+
}
|
|
103
|
+
export function importBranch(model) {
|
|
104
|
+
return mindMapToJson(model, true).content ?? [];
|
|
105
|
+
}
|
|
106
|
+
export function parseMindBranch(raw) {
|
|
107
|
+
if (!raw.startsWith('YWIKI_MINDMAP_BRANCH_V1\n') || raw.length > 2_000_000)
|
|
108
|
+
throw new Error('不是有效的导图分支');
|
|
109
|
+
const content = JSON.parse(raw.slice(raw.indexOf('\n') + 1));
|
|
110
|
+
if (!Array.isArray(content) || content.length > 1500)
|
|
111
|
+
throw new Error('分支内容过大');
|
|
112
|
+
const topics = content.filter(t => t?.type === 'mindMapTopic');
|
|
113
|
+
if (!topics.length || !topics[0].attrs)
|
|
114
|
+
throw new Error('分支缺少主题');
|
|
115
|
+
const normalized = content.map(t => t === topics[0] ? { ...t, attrs: { ...t.attrs, parentId: '' } } : t);
|
|
116
|
+
const model = readMindMap({ type: 'mindMap', attrs: { id: 'clipboard', rootId: topics[0].attrs.id,
|
|
117
|
+
formatVersion: MIND_MAP_VERSION, layout: 'right', theme: 'blue', height: 380 }, content: normalized });
|
|
118
|
+
if (!model)
|
|
119
|
+
throw new Error('分支结构不合法');
|
|
120
|
+
return importBranch(parseMindMapImport(JSON.stringify(mindMapToJson(model))));
|
|
121
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { isSafeHref } from './safe-href.js';
|
|
2
|
+
import { readableTextColor } from './presence.js';
|
|
3
|
+
import { mindTopicLineHeight, mindTopicTextSize, MIND_TOPIC_FONT } from './mind-topic-text.js';
|
|
4
|
+
/** Engine-independent document format. Topic text remains ordinary ProseMirror text. */
|
|
5
|
+
export const MIND_MAP_VERSION = 3;
|
|
6
|
+
export const MIND_MAP_MAX_TOPICS = 500;
|
|
7
|
+
export const MIND_MAP_MAX_TEXT = 2000;
|
|
8
|
+
export const MIND_MAP_LAYOUTS = ['right', 'left', 'both', 'down'];
|
|
9
|
+
export const MIND_MAP_THEMES = ['blue', 'teal', 'violet', 'amber'];
|
|
10
|
+
export const mindMapId = () => crypto.randomUUID();
|
|
11
|
+
const validId = (id) => typeof id === 'string' && /^[\w-]{1,80}$/.test(id);
|
|
12
|
+
export function createMindMap(layout = 'right') {
|
|
13
|
+
const id = mindMapId(), rootId = mindMapId();
|
|
14
|
+
return {
|
|
15
|
+
type: 'mindMap', attrs: { id, rootId, formatVersion: MIND_MAP_VERSION, layout, theme: 'blue', height: 380 },
|
|
16
|
+
content: [
|
|
17
|
+
{ type: 'mindMapTopic', attrs: { id: rootId, parentId: '', order: 0 }, content: [{ type: 'text', text: '中心主题' }] },
|
|
18
|
+
...['分支主题 1', '分支主题 2', '分支主题 3'].map((text, order) => ({
|
|
19
|
+
type: 'mindMapTopic', attrs: { id: mindMapId(), parentId: rootId, order },
|
|
20
|
+
content: [{ type: 'text', text }],
|
|
21
|
+
})),
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function readMindMap(node) {
|
|
26
|
+
const a = node?.attrs;
|
|
27
|
+
if (node?.type !== 'mindMap' || !a || ![1, 2, MIND_MAP_VERSION].includes(Number(a.formatVersion)) ||
|
|
28
|
+
!validId(a.id) || !validId(a.rootId) || !Array.isArray(node.content) ||
|
|
29
|
+
node.content.length < 1 || node.content.filter(t => t?.type === 'mindMapTopic').length > MIND_MAP_MAX_TOPICS || node.content.length > 1500)
|
|
30
|
+
return null;
|
|
31
|
+
const topics = [];
|
|
32
|
+
const ids = new Set();
|
|
33
|
+
for (const topic of node.content.filter(t => t?.type !== 'mindMapRelation')) {
|
|
34
|
+
if (!topic || typeof topic !== 'object')
|
|
35
|
+
return null;
|
|
36
|
+
const t = topic.attrs;
|
|
37
|
+
if (topic.type !== 'mindMapTopic' || !t || !validId(t.id) || ids.has(t.id) ||
|
|
38
|
+
typeof t.parentId !== 'string' || (t.parentId !== '' && !validId(t.parentId)) ||
|
|
39
|
+
typeof t.order !== 'number' || !Number.isFinite(t.order) ||
|
|
40
|
+
Math.abs(t.order) > 1e12 || (topic.content !== undefined && !Array.isArray(topic.content)) ||
|
|
41
|
+
topic.content?.some(c => !c || !['text', 'mindMapDescription', 'mindMapNote'].includes(c.type ?? '') || (c.type === 'text' && typeof c.text !== 'string')))
|
|
42
|
+
return null;
|
|
43
|
+
const text = (topic.content ?? []).filter(c => c.type === 'text').map(c => c.text ?? '').join('');
|
|
44
|
+
if (text.length > MIND_MAP_MAX_TEXT)
|
|
45
|
+
return null;
|
|
46
|
+
ids.add(t.id);
|
|
47
|
+
const extra = {};
|
|
48
|
+
if (t.color && !/^#[0-9a-f]{6}$/i.test(String(t.color)))
|
|
49
|
+
return null;
|
|
50
|
+
if (t.background && !/^#[0-9a-f]{6}$/i.test(String(t.background)))
|
|
51
|
+
return null;
|
|
52
|
+
for (const key of ['color', 'background'])
|
|
53
|
+
if (t[key] && !['#18243a', '#ffffff', '#3864ef', '#087f8c', '#7950c7', '#a66b08', '#d94848', '#f3f5fb', '#dce7ff', '#d3f4e9', '#ece0ff', '#fff0c2', '#ffe1e1', '#233044'].includes(String(t[key])))
|
|
54
|
+
return null;
|
|
55
|
+
if (t.shape && !['round', 'pill', 'text'].includes(String(t.shape)))
|
|
56
|
+
return null;
|
|
57
|
+
if (t.fontSize && ![12, 14, 16, 18, 20].includes(Number(t.fontSize)))
|
|
58
|
+
return null;
|
|
59
|
+
if (t.align && !['left', 'center', 'right'].includes(String(t.align)))
|
|
60
|
+
return null;
|
|
61
|
+
if (t.done !== undefined && t.done !== null && typeof t.done !== 'boolean')
|
|
62
|
+
return null;
|
|
63
|
+
if (t.side !== undefined && t.side !== null && t.side !== -1 && t.side !== 1)
|
|
64
|
+
return null;
|
|
65
|
+
if (t.icon && !['💡', '🎯', '📌', '🚩', '⭐', '✅', '⚠️', '💬', '📅', '🔎'].includes(String(t.icon)))
|
|
66
|
+
return null;
|
|
67
|
+
if (t.attachmentId && !(typeof t.attachmentId === 'string' && /^[0-9a-f-]{36}$/i.test(t.attachmentId)))
|
|
68
|
+
return null;
|
|
69
|
+
const fields = new Set();
|
|
70
|
+
let fieldsStarted = false, noteStarted = false;
|
|
71
|
+
for (const child of topic.content ?? []) {
|
|
72
|
+
if (child.marks !== undefined && (!Array.isArray(child.marks) || child.marks.some(m => !m || !['bold', 'italic', 'strike', 'textStyle', 'highlight', 'link'].includes(m.type))))
|
|
73
|
+
return null;
|
|
74
|
+
if (child.type !== 'text' && ((child.content !== undefined && !Array.isArray(child.content)) || child.content?.some(n => n.type !== 'text' || typeof n.text !== 'string') || (child.content ?? []).reduce((sum, n) => sum + (n.text?.length ?? 0), 0) > 4000))
|
|
75
|
+
return null;
|
|
76
|
+
if (child.type === 'text' && fieldsStarted)
|
|
77
|
+
return null;
|
|
78
|
+
if (child.type !== 'text') {
|
|
79
|
+
if (fields.has(child.type) || (child.type === 'mindMapDescription' && noteStarted))
|
|
80
|
+
return null;
|
|
81
|
+
fields.add(child.type);
|
|
82
|
+
fieldsStarted = true;
|
|
83
|
+
if (child.type === 'mindMapNote')
|
|
84
|
+
noteStarted = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const key of ['shape', 'color', 'background', 'fontSize', 'align', 'icon', 'done', 'attachmentId', 'side'])
|
|
88
|
+
if (t[key] !== undefined && t[key] !== null && t[key] !== '')
|
|
89
|
+
Object.assign(extra, { [key]: t[key] });
|
|
90
|
+
const runs = (topic.content ?? []).filter(c => c.type === 'text').map(c => ({ text: c.text ?? '', ...(c.marks?.length ? { marks: c.marks } : {}) }));
|
|
91
|
+
if (runs.some(r => r.marks?.length))
|
|
92
|
+
extra.runs = runs;
|
|
93
|
+
for (const [type, key] of [['mindMapDescription', 'description'], ['mindMapNote', 'note']]) {
|
|
94
|
+
const child = topic.content?.find(c => c.type === type);
|
|
95
|
+
if (child)
|
|
96
|
+
extra[key] = (child.content ?? []).map(c => c.text ?? '').join('');
|
|
97
|
+
}
|
|
98
|
+
topics.push({ id: t.id, parentId: t.parentId, order: t.order, text, ...extra });
|
|
99
|
+
}
|
|
100
|
+
if (!ids.has(a.rootId))
|
|
101
|
+
return null;
|
|
102
|
+
const relations = node.content.filter(c => c.type === 'mindMapRelation');
|
|
103
|
+
const relationIds = new Set();
|
|
104
|
+
for (const r of relations) {
|
|
105
|
+
const p = r.attrs;
|
|
106
|
+
if (!p || !validId(p.id) || ids.has(p.id) || relationIds.has(p.id) || !validId(p.source) || !validId(p.target) ||
|
|
107
|
+
p.source === p.target || !['related', 'depends', 'reference'].includes(String(p.kind)) ||
|
|
108
|
+
typeof p.label !== 'string' || p.label.length > 200)
|
|
109
|
+
return null;
|
|
110
|
+
relationIds.add(p.id);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
id: a.id, rootId: a.rootId, topics,
|
|
114
|
+
...(a.branchStyle ? { branchStyle: a.branchStyle } : {}),
|
|
115
|
+
...(node.content.some(c => c.type === 'mindMapRelation') ? { relations: node.content.filter(c => c.type === 'mindMapRelation').map(c => ({ ...c.attrs })) } : {}),
|
|
116
|
+
layout: MIND_MAP_LAYOUTS.includes(a.layout) ? a.layout : 'right',
|
|
117
|
+
theme: MIND_MAP_THEMES.includes(a.theme) ? a.theme : 'blue',
|
|
118
|
+
height: typeof a.height === 'number' && Number.isFinite(a.height) ? Math.min(800, Math.max(240, a.height)) : 380,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Missing parents and concurrent cycles keep every surviving topic, deterministically. */
|
|
122
|
+
export function mindMapTree(model) {
|
|
123
|
+
const topics = model.topics.map(t => ({ ...t }));
|
|
124
|
+
const byId = new Map(topics.map(t => [t.id, t]));
|
|
125
|
+
for (const t of topics) {
|
|
126
|
+
if (t.id === model.rootId)
|
|
127
|
+
t.parentId = '';
|
|
128
|
+
else if (!byId.has(t.parentId) || t.parentId === t.id)
|
|
129
|
+
t.parentId = model.rootId;
|
|
130
|
+
}
|
|
131
|
+
for (const start of [...topics].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
132
|
+
const path = [], seen = new Set();
|
|
133
|
+
let current = start;
|
|
134
|
+
while (current && current.id !== model.rootId) {
|
|
135
|
+
if (seen.has(current.id)) {
|
|
136
|
+
const cycle = path.slice(path.indexOf(current.id)).sort();
|
|
137
|
+
byId.get(cycle[0]).parentId = model.rootId;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
seen.add(current.id);
|
|
141
|
+
path.push(current.id);
|
|
142
|
+
current = byId.get(current.parentId);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return topics.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
|
146
|
+
}
|
|
147
|
+
export function mindMapDescendants(model, id) {
|
|
148
|
+
const tree = mindMapTree(model), ids = new Set([id]);
|
|
149
|
+
for (let changed = true; changed;) {
|
|
150
|
+
changed = false;
|
|
151
|
+
for (const t of tree)
|
|
152
|
+
if (ids.has(t.parentId) && !ids.has(t.id)) {
|
|
153
|
+
ids.add(t.id);
|
|
154
|
+
changed = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return ids;
|
|
158
|
+
}
|
|
159
|
+
export function canMoveMindTopic(model, id, parentId) {
|
|
160
|
+
return id !== model.rootId && model.topics.some(t => t.id === id) &&
|
|
161
|
+
model.topics.some(t => t.id === parentId) && !mindMapDescendants(model, id).has(parentId);
|
|
162
|
+
}
|
|
163
|
+
/** Only first-level topics own a side; all descendants inherit their branch direction. */
|
|
164
|
+
export function mindMapBranchSides(model) {
|
|
165
|
+
return new Map(mindMapTree(model).filter(t => t.parentId === model.rootId).map((t, i) => [t.id, model.layout === 'left' ? -1 : model.layout === 'both' ? t.side ?? (i % 2 ? -1 : 1) : 1]));
|
|
166
|
+
}
|
|
167
|
+
/** Shared by the live drop preview and the single document transaction on release. */
|
|
168
|
+
export function projectMindSideMove(model, id, side, beforeId) {
|
|
169
|
+
if (model.layout === 'down' || (side !== -1 && side !== 1) || !canMoveMindTopic(model, id, model.rootId))
|
|
170
|
+
return null;
|
|
171
|
+
const sides = mindMapBranchSides(model);
|
|
172
|
+
const siblings = mindMapTree(model).filter(t => t.parentId === model.rootId && t.id !== id && sides.get(t.id) === side);
|
|
173
|
+
const index = beforeId ? siblings.findIndex(t => t.id === beforeId) : siblings.length;
|
|
174
|
+
if (index < 0)
|
|
175
|
+
return null;
|
|
176
|
+
const order = siblings.map(t => t.id);
|
|
177
|
+
order.splice(index, 0, id);
|
|
178
|
+
const orders = new Map(order.map((key, i) => [key, i * 1024]));
|
|
179
|
+
return { ...model, layout: 'both', topics: model.topics.map(t => t.id === id
|
|
180
|
+
? { ...t, parentId: model.rootId, side, order: orders.get(id) }
|
|
181
|
+
: t.parentId === model.rootId ? { ...t, side: sides.get(t.id), order: orders.get(t.id) ?? t.order } : t) };
|
|
182
|
+
}
|
|
183
|
+
export function textDifference(before, after) {
|
|
184
|
+
let from = 0, tail = 0;
|
|
185
|
+
while (from < before.length && from < after.length && before[from] === after[from])
|
|
186
|
+
from++;
|
|
187
|
+
// Do not split surrogate pairs when inserting/removing emoji.
|
|
188
|
+
if (from > 0 && /[\uD800-\uDBFF]/.test(before[from - 1]))
|
|
189
|
+
from--;
|
|
190
|
+
while (tail < before.length - from && tail < after.length - from &&
|
|
191
|
+
before[before.length - tail - 1] === after[after.length - tail - 1])
|
|
192
|
+
tail++;
|
|
193
|
+
if (tail > 0 && /[\uDC00-\uDFFF]/.test(before[before.length - tail]))
|
|
194
|
+
tail--;
|
|
195
|
+
return { from, to: before.length - tail, text: after.slice(from, after.length - tail) };
|
|
196
|
+
}
|
|
197
|
+
function wrapTopic(text, maxUnits = 14) {
|
|
198
|
+
const lines = [];
|
|
199
|
+
for (const line of (text || '未命名主题').split('\n')) {
|
|
200
|
+
let part = '', units = 0;
|
|
201
|
+
for (const ch of line) {
|
|
202
|
+
const size = /[\u0000-\u00ff]/.test(ch) ? 0.6 : 1;
|
|
203
|
+
if (units + size > maxUnits) {
|
|
204
|
+
lines.push(part);
|
|
205
|
+
part = '';
|
|
206
|
+
units = 0;
|
|
207
|
+
}
|
|
208
|
+
part += ch;
|
|
209
|
+
units += size;
|
|
210
|
+
}
|
|
211
|
+
lines.push(part || ' ');
|
|
212
|
+
}
|
|
213
|
+
return lines;
|
|
214
|
+
}
|
|
215
|
+
export function layoutMindMap(model, collapsed = new Set()) {
|
|
216
|
+
const tree = mindMapTree(model), byParent = new Map();
|
|
217
|
+
for (const t of tree) {
|
|
218
|
+
const list = byParent.get(t.parentId) ?? [];
|
|
219
|
+
list.push(t);
|
|
220
|
+
byParent.set(t.parentId, list);
|
|
221
|
+
}
|
|
222
|
+
const root = tree.find(t => t.id === model.rootId);
|
|
223
|
+
const dimensions = new Map(tree.map(t => {
|
|
224
|
+
const measured = mindTopicTextSize(t.text, t.fontSize || 14, t.id === root.id, t.description || '', !!t.runs?.some(r => r.marks?.some(m => m.type === 'bold')), t.attachmentId ? 180 : 0);
|
|
225
|
+
return [t.id, { lines: measured.lines, width: measured.width,
|
|
226
|
+
height: Math.max(44, measured.titleHeight + 22) + (t.description ? measured.descriptionLines.length * 18 + 6 : 0) +
|
|
227
|
+
(t.attachmentId ? 104 : 0) + (t.icon || t.done ? 20 : 0) + (t.note ? 20 : 0) }];
|
|
228
|
+
}));
|
|
229
|
+
const kids = (id) => collapsed.has(id) ? [] : byParent.get(id) ?? [];
|
|
230
|
+
const nodes = [];
|
|
231
|
+
const span = (t) => Math.max(model.layout === 'down' ? dimensions.get(t.id).width : dimensions.get(t.id).height, kids(t.id).reduce((n, c) => n + span(c) + 20, -20));
|
|
232
|
+
const place = (t, x, y, side) => {
|
|
233
|
+
const d = dimensions.get(t.id);
|
|
234
|
+
nodes.push({ ...t, ...d, x, y, side });
|
|
235
|
+
const children = kids(t.id), total = children.reduce((n, c) => n + span(c) + 20, -20);
|
|
236
|
+
let cursor = (model.layout === 'down' ? x + d.width / 2 : y + d.height / 2) - total / 2;
|
|
237
|
+
for (const c of children) {
|
|
238
|
+
const cd = dimensions.get(c.id), s = span(c);
|
|
239
|
+
if (model.layout === 'down')
|
|
240
|
+
place(c, cursor + s / 2 - cd.width / 2, y + d.height + 60, side);
|
|
241
|
+
else
|
|
242
|
+
place(c, side === 1 ? x + d.width + 64 : x - cd.width - 64, cursor + s / 2 - cd.height / 2, side);
|
|
243
|
+
cursor += s + 20;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
if (model.layout === 'both') {
|
|
247
|
+
const d = dimensions.get(root.id);
|
|
248
|
+
nodes.push({ ...root, ...d, x: 0, y: 0, side: 1 });
|
|
249
|
+
const children = kids(root.id);
|
|
250
|
+
const sides = mindMapBranchSides(model);
|
|
251
|
+
for (const side of [-1, 1]) {
|
|
252
|
+
const branches = children.filter(c => sides.get(c.id) === side);
|
|
253
|
+
let cursor = d.height / 2 - branches.reduce((n, c) => n + span(c) + 20, -20) / 2;
|
|
254
|
+
for (const c of branches) {
|
|
255
|
+
const cd = dimensions.get(c.id), s = span(c);
|
|
256
|
+
place(c, side === 1 ? d.width + 64 : -cd.width - 64, cursor + s / 2 - cd.height / 2, side);
|
|
257
|
+
cursor += s + 20;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else
|
|
262
|
+
place(root, 0, 0, model.layout === 'left' ? -1 : 1);
|
|
263
|
+
const placed = new Map(nodes.map(n => [n.id, n]));
|
|
264
|
+
const edges = nodes.filter(n => n.id !== root.id).map(n => {
|
|
265
|
+
const p = placed.get(n.parentId);
|
|
266
|
+
let x1, y1, x2, y2, path;
|
|
267
|
+
if (model.layout === 'down') {
|
|
268
|
+
x1 = p.x + p.width / 2;
|
|
269
|
+
y1 = p.y + p.height;
|
|
270
|
+
x2 = n.x + n.width / 2;
|
|
271
|
+
y2 = n.y;
|
|
272
|
+
path = `M${x1},${y1} C${x1},${(y1 + y2) / 2} ${x2},${(y1 + y2) / 2} ${x2},${y2}`;
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
x1 = p.x + (n.side === 1 ? p.width : 0);
|
|
276
|
+
y1 = p.y + p.height / 2;
|
|
277
|
+
x2 = n.x + (n.side === 1 ? 0 : n.width);
|
|
278
|
+
y2 = n.y + n.height / 2;
|
|
279
|
+
path = `M${x1},${y1} C${(x1 + x2) / 2},${y1} ${(x1 + x2) / 2},${y2} ${x2},${y2}`;
|
|
280
|
+
}
|
|
281
|
+
if (model.branchStyle === 'straight')
|
|
282
|
+
path = `M${x1},${y1} L${x2},${y2}`;
|
|
283
|
+
if (model.branchStyle === 'step')
|
|
284
|
+
path = model.layout === 'down'
|
|
285
|
+
? `M${x1},${y1} V${(y1 + y2) / 2} H${x2} V${y2}` : `M${x1},${y1} H${(x1 + x2) / 2} V${y2} H${x2}`;
|
|
286
|
+
return { id: `${p.id}-${n.id}`, source: p.id, target: n.id, path };
|
|
287
|
+
});
|
|
288
|
+
const relationBounds = (model.relations ?? []).flatMap(r => {
|
|
289
|
+
const a = placed.get(r.source), b = placed.get(r.target);
|
|
290
|
+
if (!a || !b)
|
|
291
|
+
return [];
|
|
292
|
+
const center = (a.x + a.width / 2 + b.x + b.width / 2) / 2, half = [...r.label].length * 7 / 2;
|
|
293
|
+
return [{ left: center - half, right: center + half, top: Math.min(a.y, b.y) - 68 }];
|
|
294
|
+
});
|
|
295
|
+
const x = Math.min(...nodes.map(n => n.x), ...relationBounds.map(r => r.left)) - 32, y = Math.min(...nodes.map(n => n.y), ...relationBounds.map(r => r.top)) - 32;
|
|
296
|
+
return { nodes, edges, bounds: { x, y,
|
|
297
|
+
width: Math.max(...nodes.map(n => n.x + n.width), ...relationBounds.map(r => r.right)) - x + 32,
|
|
298
|
+
height: Math.max(...nodes.map(n => n.y + n.height)) - y + 32 } };
|
|
299
|
+
}
|
|
300
|
+
export const MIND_MAP_PALETTE = {
|
|
301
|
+
blue: '#3864ef', teal: '#087f8c', violet: '#7950c7', amber: '#a66b08',
|
|
302
|
+
};
|
|
303
|
+
const escapeXml = (s) => s.replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
304
|
+
function svgRichLine(topic, line, offset) {
|
|
305
|
+
if (!topic.runs?.length)
|
|
306
|
+
return escapeXml(line);
|
|
307
|
+
const chars = [];
|
|
308
|
+
let position = offset;
|
|
309
|
+
for (const ch of line) {
|
|
310
|
+
let cursor = 0;
|
|
311
|
+
const run = topic.runs.find(r => { const start = cursor; cursor += r.text.length; return position >= start && position < cursor; });
|
|
312
|
+
const marks = run?.marks ?? [], style = [];
|
|
313
|
+
for (const m of marks) {
|
|
314
|
+
if (m.type === 'bold')
|
|
315
|
+
style.push('font-weight:700');
|
|
316
|
+
if (m.type === 'italic')
|
|
317
|
+
style.push('font-style:italic');
|
|
318
|
+
if (m.type === 'strike')
|
|
319
|
+
style.push('text-decoration:line-through');
|
|
320
|
+
if (m.type === 'link')
|
|
321
|
+
style.push('text-decoration:underline');
|
|
322
|
+
if (m.type === 'textStyle' && /^#[0-9a-f]{6}$/i.test(String(m.attrs?.color)))
|
|
323
|
+
style.push(`fill:${m.attrs.color}`);
|
|
324
|
+
}
|
|
325
|
+
chars.push(`<tspan${style.length ? ` style="${style.join(';')}"` : ''}>${escapeXml(ch)}</tspan>`);
|
|
326
|
+
position += ch.length;
|
|
327
|
+
}
|
|
328
|
+
return chars.join('');
|
|
329
|
+
}
|
|
330
|
+
function svgHighlights(topic, line, offset, x, y) {
|
|
331
|
+
if (!topic.runs?.some(r => r.marks?.some(m => m.type === 'highlight')))
|
|
332
|
+
return '';
|
|
333
|
+
const font = topic.fontSize || 14;
|
|
334
|
+
const width = (ch) => (/[\u0000-\u00ff]/.test(ch) ? 8 : 14) * font / 14;
|
|
335
|
+
const total = [...line].reduce((sum, ch) => sum + width(ch), 0);
|
|
336
|
+
let left = topic.align === 'left' ? x + 16 : topic.align === 'right' ? x + topic.width - total - 16 : x + (topic.width - total) / 2;
|
|
337
|
+
let position = offset;
|
|
338
|
+
const rects = [];
|
|
339
|
+
for (const ch of line) {
|
|
340
|
+
let cursor = 0;
|
|
341
|
+
const run = topic.runs.find(r => { const start = cursor; cursor += r.text.length; return position >= start && position < cursor; });
|
|
342
|
+
const color = run?.marks?.find(m => m.type === 'highlight')?.attrs?.color;
|
|
343
|
+
const w = width(ch);
|
|
344
|
+
if (/^#[0-9a-f]{6}$/i.test(String(color)))
|
|
345
|
+
rects.push(`<rect x="${left}" y="${y - font}" width="${w}" height="${font + 4}" rx="2" fill="${color}"/>`);
|
|
346
|
+
left += w;
|
|
347
|
+
position += ch.length;
|
|
348
|
+
}
|
|
349
|
+
return rects.join('');
|
|
350
|
+
}
|
|
351
|
+
export function mindMapSvg(model, options = {}) {
|
|
352
|
+
const drawing = layoutMindMap(model), b = drawing.bounds, color = MIND_MAP_PALETTE[model.theme];
|
|
353
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${b.x} ${b.y} ${b.width} ${b.height}" width="${Math.ceil(b.width)}" height="${Math.ceil(b.height)}">` +
|
|
354
|
+
(options.transparent ? '' : `<rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" fill="#fff"/>`) +
|
|
355
|
+
drawing.edges.map(e => `<path d="${e.path}" fill="none" stroke="${color}" stroke-width="2"/>`).join('') +
|
|
356
|
+
drawing.nodes.map(n => {
|
|
357
|
+
const lineHeight = mindTopicLineHeight(n.fontSize || 14), baseline = 11 + (lineHeight - (n.fontSize || 14)) / 2 + (n.fontSize || 14) * .8;
|
|
358
|
+
const fill = n.background || (n.id === model.rootId ? color : '#f3f5fb');
|
|
359
|
+
const ink = n.color || (n.shape === 'text' ? '#25314a' : n.background ? readableTextColor(n.background) : n.id === model.rootId ? '#fff' : '#25314a');
|
|
360
|
+
const description = n.description ? wrapTopic(n.description) : [];
|
|
361
|
+
const image = n.attachmentId && options.images?.[n.attachmentId];
|
|
362
|
+
return `<g>` + (n.shape === 'text' ? '' : `<rect x="${n.x}" y="${n.y}" width="${n.width}" height="${n.height}" rx="${n.shape === 'pill' ? 24 : 9}" fill="${escapeXml(fill)}" stroke="${color}" stroke-opacity=".3"/>`) +
|
|
363
|
+
(n.icon || n.done ? `<text x="${n.x + n.width / 2}" y="${n.y + 22}" text-anchor="middle" font-size="16">${n.done ? '☑ ' : ''}${escapeXml(n.icon || '')}</text>` : '') +
|
|
364
|
+
n.lines.map((line, i) => svgHighlights(n, line, n.lines.slice(0, i).reduce((sum, l) => sum + l.length, 0), n.x, n.y + baseline + i * lineHeight + (n.icon || n.done ? 20 : 0))).join('') +
|
|
365
|
+
n.lines.map((line, i) => `<text x="${n.x + (n.align === 'left' ? 16 : n.align === 'right' ? n.width - 16 : n.width / 2)}" y="${n.y + baseline + i * lineHeight + (n.icon || n.done ? 20 : 0)}" text-anchor="${n.align === 'left' ? 'start' : n.align === 'right' ? 'end' : 'middle'}" font-family="${escapeXml(MIND_TOPIC_FONT)}" font-size="${n.fontSize || 14}" fill="${escapeXml(ink)}">${svgRichLine(n, line, n.lines.slice(0, i).reduce((sum, l) => sum + l.length, 0))}</text>`).join('') +
|
|
366
|
+
description.map((line, i) => `<text x="${n.x + n.width / 2}" y="${n.y + 28 + n.lines.length * lineHeight + i * 18 + (n.icon || n.done ? 20 : 0)}" text-anchor="middle" font-size="12" fill="#677184">${escapeXml(line)}</text>`).join('') +
|
|
367
|
+
(n.note ? `<text x="${n.x + n.width / 2}" y="${n.y + n.height - (n.attachmentId ? 112 : 10)}" text-anchor="middle" font-size="10" fill="#677184">▤ 备注</text>` : '') +
|
|
368
|
+
(image && /^data:image\/(png|jpeg|webp|gif);base64,/.test(image) ? `<image x="${n.x + 10}" y="${n.y + n.height - 105}" width="${n.width - 20}" height="95" href="${image}" preserveAspectRatio="xMidYMid meet"/>` : '') + '</g>';
|
|
369
|
+
}).join('') + mindRelationPaths(model, drawing).map(r => `<path d="${r.path}" fill="none" stroke="#8b5fc7" stroke-width="2" stroke-dasharray="6 4"/><text x="${r.x}" y="${r.y}" font-size="12" fill="#7950c7">${escapeXml(r.label || ({ related: '相关', depends: '依赖', reference: '参考' }[r.kind]))}</text>`).join('') + '</svg>';
|
|
370
|
+
}
|
|
371
|
+
export function mindMapOutline(model) {
|
|
372
|
+
const tree = mindMapTree(model), result = [];
|
|
373
|
+
const visit = (id, depth) => {
|
|
374
|
+
const topic = tree.find(t => t.id === id);
|
|
375
|
+
if (!topic)
|
|
376
|
+
return;
|
|
377
|
+
result.push({ ...topic, depth });
|
|
378
|
+
for (const child of tree.filter(t => t.parentId === id))
|
|
379
|
+
visit(child.id, depth + 1);
|
|
380
|
+
};
|
|
381
|
+
visit(model.rootId, 0);
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
export function mindMapMarkdown(model) {
|
|
385
|
+
const escape = (value) => value.replace(/([\\`*_[\]<>])/g, '\\$1').replace(/\n/g, ' ');
|
|
386
|
+
return mindMapOutline(model).map(t => {
|
|
387
|
+
const title = (t.runs ?? [{ text: t.text }]).map(run => {
|
|
388
|
+
let value = escape(run.text);
|
|
389
|
+
for (const mark of run.marks ?? []) {
|
|
390
|
+
if (mark.type === 'bold')
|
|
391
|
+
value = `**${value}**`;
|
|
392
|
+
if (mark.type === 'italic')
|
|
393
|
+
value = `*${value}*`;
|
|
394
|
+
if (mark.type === 'strike')
|
|
395
|
+
value = `~~${value}~~`;
|
|
396
|
+
if (mark.type === 'link' && isSafeHref(mark.attrs?.href))
|
|
397
|
+
value = `[${value}](${String(mark.attrs.href).replace(/[()]/g, ch => encodeURIComponent(ch))})`;
|
|
398
|
+
}
|
|
399
|
+
return value;
|
|
400
|
+
}).join('') || '未命名主题';
|
|
401
|
+
return `${' '.repeat(t.depth)}- ${t.done ? '[x] ' : ''}${t.icon ? t.icon + ' ' : ''}${title}${t.description ? ' — ' + escape(t.description) : ''}${t.note ? '(备注:' + escape(t.note) + ')' : ''}${t.attachmentId ? ' [图片附件:' + t.attachmentId + ']' : ''}`;
|
|
402
|
+
}).join('\n') + ((model.relations?.length ?? 0) > 0 ? '\n\n关系:\n' + model.relations.map(r => `- ${escape(model.topics.find(t => t.id === r.source)?.text || '已移除主题')} → ${escape(model.topics.find(t => t.id === r.target)?.text || '已移除主题')}:${escape(r.label || r.kind)}`).join('\n') : '');
|
|
403
|
+
}
|
|
404
|
+
export function mindRelationPaths(model, drawing) {
|
|
405
|
+
const nodes = new Map(drawing.nodes.map(n => [n.id, n]));
|
|
406
|
+
return (model.relations ?? []).flatMap(r => {
|
|
407
|
+
const a = nodes.get(r.source), b = nodes.get(r.target);
|
|
408
|
+
if (!a || !b || a === b)
|
|
409
|
+
return [];
|
|
410
|
+
const x1 = a.x + a.width / 2, y1 = a.y, x2 = b.x + b.width / 2, y2 = b.y;
|
|
411
|
+
const bend = Math.min(y1, y2) - 48;
|
|
412
|
+
return [{ ...r, path: `M${x1},${y1} C${x1},${bend} ${x2},${bend} ${x2},${y2}`, x: (x1 + x2) / 2, y: bend + 8 }];
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
export function mindMapToJson(model, fresh = false) {
|
|
416
|
+
const ids = new Map(model.topics.map(t => [t.id, fresh ? mindMapId() : t.id]));
|
|
417
|
+
return { type: 'mindMap', attrs: { id: fresh ? mindMapId() : model.id, rootId: ids.get(model.rootId),
|
|
418
|
+
formatVersion: MIND_MAP_VERSION, layout: model.layout, theme: model.theme, height: model.height, branchStyle: model.branchStyle || 'curve' },
|
|
419
|
+
content: [...model.topics.map(t => {
|
|
420
|
+
const { text, runs, description, note, ...attrs } = t;
|
|
421
|
+
return { type: 'mindMapTopic', attrs: { ...attrs, id: ids.get(t.id), parentId: ids.get(t.parentId) ?? '' },
|
|
422
|
+
content: [...(runs ?? (text ? [{ text }] : [])).map(r => ({ type: 'text', ...r })),
|
|
423
|
+
...(description ? [{ type: 'mindMapDescription', content: [{ type: 'text', text: description }] }] : []),
|
|
424
|
+
...(note ? [{ type: 'mindMapNote', content: [{ type: 'text', text: note }] }] : [])] };
|
|
425
|
+
}), ...(model.relations ?? []).map(r => ({ type: 'mindMapRelation', attrs: { ...r,
|
|
426
|
+
id: fresh ? mindMapId() : r.id, source: ids.get(r.source), target: ids.get(r.target) } }))] };
|
|
427
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const MIND_TOPIC_LINE_HEIGHT = 22;
|
|
2
|
+
export const mindTopicLineHeight = (fontSize = 14) => Math.max(MIND_TOPIC_LINE_HEIGHT, Math.ceil(fontSize * 1.4));
|
|
3
|
+
export const MIND_TOPIC_PADDING = 34; // 16px * 2 padding + 1px * 2 borders.
|
|
4
|
+
export const MIND_TOPIC_FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif';
|
|
5
|
+
const sizeCache = new Map();
|
|
6
|
+
let factory;
|
|
7
|
+
export function setMindTextMeasureFactory(value) { factory = value; sizeCache.clear(); }
|
|
8
|
+
/** Real browser font metrics; non-DOM exports/tests keep a conservative text-only fallback. */
|
|
9
|
+
export function mindTextMeasure(fontSize = 14, weight = 400) {
|
|
10
|
+
const measured = factory?.(fontSize, weight);
|
|
11
|
+
if (measured)
|
|
12
|
+
return measured;
|
|
13
|
+
return text => [...text].reduce((sum, ch) => sum + (/[\u0000-\u00ff]/.test(ch) ? 9 : 14) * fontSize / 14, 0);
|
|
14
|
+
}
|
|
15
|
+
/** Keep explicit empty lines and emoji clusters; presentation wrapping never edits the source. */
|
|
16
|
+
export function wrapMindText(text, width, measure) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
const segmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
|
|
19
|
+
for (const line of text.split('\n')) {
|
|
20
|
+
let part = '';
|
|
21
|
+
const segments = segmenter ? [...segmenter.segment(line)].map(s => s.segment) : [...line];
|
|
22
|
+
for (const ch of segments) {
|
|
23
|
+
if (part && measure(part + ch) > width) {
|
|
24
|
+
lines.push(part);
|
|
25
|
+
part = '';
|
|
26
|
+
}
|
|
27
|
+
part += ch;
|
|
28
|
+
}
|
|
29
|
+
lines.push(part);
|
|
30
|
+
}
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
33
|
+
export function mindTopicTextSize(text, fontSize = 14, root = false, description = '', bold = false, minimumWidth = 0) {
|
|
34
|
+
const key = JSON.stringify([text, fontSize, root, description, bold, minimumWidth]);
|
|
35
|
+
const cached = sizeCache.get(key);
|
|
36
|
+
if (cached)
|
|
37
|
+
return cached;
|
|
38
|
+
const measure = mindTextMeasure(fontSize, bold ? 700 : root ? 600 : 400), descriptionMeasure = mindTextMeasure(12);
|
|
39
|
+
const source = text || '未命名主题';
|
|
40
|
+
const longest = Math.max(...source.split('\n').map(measure), ...description.split('\n').map(descriptionMeasure));
|
|
41
|
+
const width = Math.min(240, Math.max(root ? 128 : 100, minimumWidth, Math.ceil(longest + MIND_TOPIC_PADDING + 2)));
|
|
42
|
+
const lines = wrapMindText(source, width - MIND_TOPIC_PADDING, measure);
|
|
43
|
+
const descriptionLines = description ? wrapMindText(description, width - MIND_TOPIC_PADDING, descriptionMeasure) : [];
|
|
44
|
+
const result = { width, lines, descriptionLines, titleHeight: lines.length * mindTopicLineHeight(fontSize) };
|
|
45
|
+
if (sizeCache.size >= 512)
|
|
46
|
+
sizeCache.delete(sizeCache.keys().next().value);
|
|
47
|
+
sizeCache.set(key, result);
|
|
48
|
+
return result;
|
|
49
|
+
}
|