@tnotesjs/core 0.3.0 → 0.4.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/dist/vitepress/config/index.js +157 -16
- package/package.json +3 -6
- package/vitepress/components/MindmapPreview/InlineRuns.ts +25 -0
- package/vitepress/components/MindmapPreview/MindmapOutlineNode.vue +62 -0
- package/vitepress/components/MindmapPreview/MindmapPreview.vue +287 -0
- package/vitepress/components/MindmapPreview/compat.test.ts +93 -0
- package/vitepress/components/MindmapPreview/compat.ts +128 -0
- package/vitepress/components/MindmapPreview/expandLevel.test.ts +40 -0
- package/vitepress/components/MindmapPreview/expandLevel.ts +28 -0
- package/vitepress/components/MindmapPreview/legacyCorpus.test.ts +57 -0
- package/vitepress/configs/markdown.config.ts +77 -20
- package/vitepress/theme/index.ts +4 -2
- package/vitepress/theme/styles/index.scss +0 -1
- package/vitepress/components/MarkMap/MarkMap.vue +0 -618
- package/vitepress/theme/styles/components/markmap.scss +0 -122
|
@@ -49,6 +49,103 @@ import markdownItContainer from "markdown-it-container";
|
|
|
49
49
|
import mila from "markdown-it-link-attributes";
|
|
50
50
|
import markdownItTaskLists from "markdown-it-task-lists";
|
|
51
51
|
import path from "path";
|
|
52
|
+
|
|
53
|
+
// vitepress/components/MindmapPreview/compat.ts
|
|
54
|
+
function cleanHeadingText(value) {
|
|
55
|
+
return value.trim().replace(/\s+#+\s*$/, "").trim();
|
|
56
|
+
}
|
|
57
|
+
function promoteLegacyRootList(body, rootTitle) {
|
|
58
|
+
const firstContentIndex = body.findIndex((line) => line.trim() !== "");
|
|
59
|
+
if (firstContentIndex < 0) return body;
|
|
60
|
+
const firstItem = body[firstContentIndex].match(/^[-+*]\s+(.+?)\s*$/);
|
|
61
|
+
if (!firstItem || cleanHeadingText(firstItem[1]) !== rootTitle) return body;
|
|
62
|
+
const descendants = body.slice(firstContentIndex + 1);
|
|
63
|
+
if (descendants.some((line) => line.trim() !== "" && !/^\s{2,}/.test(line))) return body;
|
|
64
|
+
return [
|
|
65
|
+
...body.slice(0, firstContentIndex),
|
|
66
|
+
...descendants.map((line) => line.replace(/^ {2}/, ""))
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
function parseMarkmapFence(openLine) {
|
|
70
|
+
const fenceBody = openLine.trim().replace(/^`+\s*/, "");
|
|
71
|
+
const nameMatch = fenceBody.match(/^(mindmap|markmap)(?=\s|\{|\[|$)/);
|
|
72
|
+
if (!nameMatch) return {};
|
|
73
|
+
let rest = fenceBody.slice(nameMatch[1].length).trim();
|
|
74
|
+
const options = {};
|
|
75
|
+
const titleMatch = rest.match(/\[([^\]]+)\]/);
|
|
76
|
+
if (titleMatch) {
|
|
77
|
+
options.title = titleMatch[1].trim();
|
|
78
|
+
rest = `${rest.slice(0, titleMatch.index)} ${rest.slice((titleMatch.index ?? 0) + titleMatch[0].length)}`.trim();
|
|
79
|
+
}
|
|
80
|
+
const braceMatch = rest.match(/\{([^}]*)\}/);
|
|
81
|
+
const paramPart = braceMatch ? braceMatch[1].trim() : rest;
|
|
82
|
+
const tokens = paramPart.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
83
|
+
for (const [index, token] of tokens.entries()) {
|
|
84
|
+
if (/^\d+$/.test(token) && index === 0) {
|
|
85
|
+
options.initialExpandLevel = Number(token);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const pair = token.match(/^([^=:\s]+)\s*(?:=|:)\s*(.+)$/);
|
|
89
|
+
if (!pair || pair[1] !== "initialExpandLevel") continue;
|
|
90
|
+
const value = pair[2].replace(/^['"]|['"]$/g, "");
|
|
91
|
+
if (/^\d+$/.test(value)) options.initialExpandLevel = Number(value);
|
|
92
|
+
}
|
|
93
|
+
return options;
|
|
94
|
+
}
|
|
95
|
+
function parseMindmapReference(line) {
|
|
96
|
+
const match = line.trim().match(/^<<<\s+(.+?)\s*$/);
|
|
97
|
+
if (!match) return null;
|
|
98
|
+
let rest = match[1].trim();
|
|
99
|
+
let title;
|
|
100
|
+
const titleMatch = rest.match(/\s+\[([^\]]+)\]\s*$/);
|
|
101
|
+
if (titleMatch) {
|
|
102
|
+
title = cleanHeadingText(titleMatch[1]) || void 0;
|
|
103
|
+
rest = rest.slice(0, titleMatch.index).trim();
|
|
104
|
+
}
|
|
105
|
+
const path6 = rest.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, "$1$2").trim();
|
|
106
|
+
return path6 ? { path: path6, title } : null;
|
|
107
|
+
}
|
|
108
|
+
function normalizeMindmapMarkdown(source, options = {}) {
|
|
109
|
+
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
110
|
+
let existingTitle = "";
|
|
111
|
+
let rootIndex = -1;
|
|
112
|
+
for (let index = 0; index < lines.length; index++) {
|
|
113
|
+
const match = lines[index].match(/^\s{0,3}#(?!#)\s+(.+?)\s*$/);
|
|
114
|
+
if (!match) continue;
|
|
115
|
+
existingTitle = cleanHeadingText(match[1]);
|
|
116
|
+
rootIndex = index;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
const rootTitle = cleanHeadingText(options.title || existingTitle || options.defaultTitle || "root") || "root";
|
|
120
|
+
const body = [];
|
|
121
|
+
let headingDepth = null;
|
|
122
|
+
for (let index = 0; index < lines.length; index++) {
|
|
123
|
+
if (index === rootIndex) continue;
|
|
124
|
+
const line = lines[index];
|
|
125
|
+
const heading = line.match(/^\s{0,3}(#{2,6})\s+(.+?)\s*$/);
|
|
126
|
+
if (heading) {
|
|
127
|
+
headingDepth = heading[1].length - 2;
|
|
128
|
+
body.push(`${" ".repeat(headingDepth)}- ${cleanHeadingText(heading[2])}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const listItem = line.match(/^(\s*)([-+*])\s+(.+)$/);
|
|
132
|
+
if (listItem && headingDepth !== null) {
|
|
133
|
+
body.push(`${" ".repeat(headingDepth + 1)}${line}`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
body.push(line);
|
|
137
|
+
}
|
|
138
|
+
const normalizedBody = promoteLegacyRootList(body, rootTitle);
|
|
139
|
+
while (normalizedBody[0]?.trim() === "") normalizedBody.shift();
|
|
140
|
+
while (normalizedBody[normalizedBody.length - 1]?.trim() === "") normalizedBody.pop();
|
|
141
|
+
return normalizedBody.length > 0 ? `# ${rootTitle}
|
|
142
|
+
|
|
143
|
+
${normalizedBody.join("\n")}
|
|
144
|
+
` : `# ${rootTitle}
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// vitepress/configs/markdown.config.ts
|
|
52
149
|
function esc(s = "") {
|
|
53
150
|
return s.replace(
|
|
54
151
|
/[&<>"']/g,
|
|
@@ -80,12 +177,11 @@ var simpleMermaidMarkdown = (md) => {
|
|
|
80
177
|
return fence(tokens, index, options, env, slf);
|
|
81
178
|
};
|
|
82
179
|
};
|
|
83
|
-
function
|
|
180
|
+
function configureMindmapContainer(md) {
|
|
84
181
|
md.use(markdownItContainer, "markmap", {
|
|
85
182
|
marker: "`",
|
|
86
183
|
validate(params) {
|
|
87
|
-
|
|
88
|
-
return p.startsWith("markmap");
|
|
184
|
+
return (params || "").trim().startsWith("markmap");
|
|
89
185
|
},
|
|
90
186
|
render() {
|
|
91
187
|
return "";
|
|
@@ -98,26 +194,34 @@ function configureMarkMapContainer(md) {
|
|
|
98
194
|
for (let i = 0; i < tokens.length; i++) {
|
|
99
195
|
const t = tokens[i];
|
|
100
196
|
if (t.type === "container_markmap_open") {
|
|
197
|
+
const containerName = "markmap";
|
|
198
|
+
const closeType = "container_markmap_close";
|
|
101
199
|
let j = i + 1;
|
|
102
|
-
while (j < tokens.length && tokens[j].type !==
|
|
200
|
+
while (j < tokens.length && tokens[j].type !== closeType)
|
|
103
201
|
j++;
|
|
104
202
|
if (j >= tokens.length) continue;
|
|
105
203
|
const open = t;
|
|
106
204
|
const startLine = open.map ? open.map[0] + 1 : null;
|
|
107
205
|
const endLine = open.map ? open.map[1] - 1 : null;
|
|
108
206
|
const params = {};
|
|
207
|
+
let explicitTitle;
|
|
109
208
|
if (open.map && typeof open.map[0] === "number") {
|
|
110
209
|
const openLine = (lines[open.map[0]] || "").trim();
|
|
210
|
+
const fenceOptions = parseMarkmapFence(openLine);
|
|
211
|
+
explicitTitle = fenceOptions.title;
|
|
111
212
|
let paramPart = "";
|
|
112
213
|
const braceMatch = openLine.match(/\{([^}]*)\}/);
|
|
113
214
|
if (braceMatch) {
|
|
114
215
|
paramPart = braceMatch[1].trim();
|
|
115
216
|
} else {
|
|
116
217
|
const after = openLine.replace(/^`+\s*/, "");
|
|
117
|
-
if (after.startsWith(
|
|
118
|
-
paramPart = after.slice(
|
|
218
|
+
if (after.startsWith(containerName)) {
|
|
219
|
+
paramPart = after.slice(containerName.length).trim();
|
|
119
220
|
}
|
|
120
221
|
}
|
|
222
|
+
if (fenceOptions.initialExpandLevel !== void 0) {
|
|
223
|
+
params.initialExpandLevel = fenceOptions.initialExpandLevel;
|
|
224
|
+
}
|
|
121
225
|
if (paramPart) {
|
|
122
226
|
const tokenArr = paramPart.match(/"[^"]*"|'[^']*'|\S+/g) || [];
|
|
123
227
|
let startIdx = 0;
|
|
@@ -153,9 +257,11 @@ function configureMarkMapContainer(md) {
|
|
|
153
257
|
}
|
|
154
258
|
}
|
|
155
259
|
const firstNonEmptyLine = (content || "").split("\n").find((ln) => ln.trim() !== "") || "";
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
260
|
+
const reference = parseMindmapReference(firstNonEmptyLine);
|
|
261
|
+
let referencedTitle;
|
|
262
|
+
if (reference) {
|
|
263
|
+
const refRaw = reference.path;
|
|
264
|
+
referencedTitle = reference.title;
|
|
159
265
|
try {
|
|
160
266
|
const env = state.env || {};
|
|
161
267
|
const possibleRel = env.relativePath || env.path || env.filePath || env.file || "";
|
|
@@ -174,13 +280,13 @@ function configureMarkMapContainer(md) {
|
|
|
174
280
|
content = fileContent;
|
|
175
281
|
} catch (err) {
|
|
176
282
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
177
|
-
content =
|
|
178
|
-
|
|
179
|
-
)}
|
|
180
|
-
|
|
181
|
-
Error: ${esc(errorMsg)}`;
|
|
283
|
+
content = `- Failed to load referenced file: ${esc(String(refRaw))}
|
|
284
|
+
- Error: ${esc(errorMsg)}`;
|
|
182
285
|
}
|
|
183
286
|
}
|
|
287
|
+
content = normalizeMindmapMarkdown(content, {
|
|
288
|
+
title: explicitTitle || referencedTitle
|
|
289
|
+
});
|
|
184
290
|
const encodedContent = encodeURIComponent(content.trim());
|
|
185
291
|
let propsStr = `content="${encodedContent}"`;
|
|
186
292
|
for (const [k, v] of Object.entries(params)) {
|
|
@@ -191,7 +297,7 @@ Error: ${esc(errorMsg)}`;
|
|
|
191
297
|
propsStr += ` ${k}="${safe}"`;
|
|
192
298
|
}
|
|
193
299
|
}
|
|
194
|
-
const html = `<
|
|
300
|
+
const html = `<MindmapPreview ${propsStr}></MindmapPreview>
|
|
195
301
|
`;
|
|
196
302
|
const htmlToken = new state.Token("html_block", "", 0);
|
|
197
303
|
htmlToken.content = html;
|
|
@@ -201,6 +307,40 @@ Error: ${esc(errorMsg)}`;
|
|
|
201
307
|
return true;
|
|
202
308
|
});
|
|
203
309
|
}
|
|
310
|
+
function configureMindmapFence(md) {
|
|
311
|
+
const fence = md.renderer.rules.fence ? md.renderer.rules.fence.bind(md.renderer.rules) : () => "";
|
|
312
|
+
md.renderer.rules.fence = (tokens, index, options, env, slf) => {
|
|
313
|
+
const token = tokens[index];
|
|
314
|
+
const info = token.info.trim();
|
|
315
|
+
if (!/^mindmap(?=\s|\{|\[|$)/.test(info)) {
|
|
316
|
+
return fence(tokens, index, options, env, slf);
|
|
317
|
+
}
|
|
318
|
+
const fenceOptions = parseMarkmapFence(info);
|
|
319
|
+
let content = token.content;
|
|
320
|
+
const firstNonEmptyLine = content.split("\n").find((line) => line.trim()) ?? "";
|
|
321
|
+
const reference = parseMindmapReference(firstNonEmptyLine);
|
|
322
|
+
if (reference) {
|
|
323
|
+
const possibleRel = env?.relativePath || env?.path || env?.filePath || env?.file || "";
|
|
324
|
+
const refFullPath = path.isAbsolute(reference.path) ? reference.path : path.resolve(process.cwd(), possibleRel ? path.dirname(possibleRel) : "", reference.path);
|
|
325
|
+
try {
|
|
326
|
+
content = fs.readFileSync(refFullPath, "utf8");
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
329
|
+
content = `- Failed to load referenced file: ${reference.path}
|
|
330
|
+
- Error: ${message}`;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
content = normalizeMindmapMarkdown(content, {
|
|
334
|
+
title: fenceOptions.title || reference?.title
|
|
335
|
+
});
|
|
336
|
+
const props = [
|
|
337
|
+
`content="${encodeURIComponent(content.trim())}"`,
|
|
338
|
+
fenceOptions.initialExpandLevel === void 0 ? "" : `:initialExpandLevel="${fenceOptions.initialExpandLevel}"`
|
|
339
|
+
].filter(Boolean).join(" ");
|
|
340
|
+
return `<MindmapPreview ${props}></MindmapPreview>
|
|
341
|
+
`;
|
|
342
|
+
};
|
|
343
|
+
}
|
|
204
344
|
function configureSwiperContainer(md) {
|
|
205
345
|
let __tn_swiper_uid = 0;
|
|
206
346
|
let __tn_rules_stack = [];
|
|
@@ -269,7 +409,8 @@ function getMarkdownConfig() {
|
|
|
269
409
|
return true;
|
|
270
410
|
});
|
|
271
411
|
simpleMermaidMarkdown(md);
|
|
272
|
-
|
|
412
|
+
configureMindmapContainer(md);
|
|
413
|
+
configureMindmapFence(md);
|
|
273
414
|
md.use(markdownItTaskLists);
|
|
274
415
|
md.use(mila, {
|
|
275
416
|
attrs: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tnotesjs/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "TNotes 知识库核心框架 —— 基于 VitePress 的笔记管理系统",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.17.1",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"vue": "^3.5.0"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"
|
|
52
|
+
"@tnotesjs/mindmap-core": "^0.2.0",
|
|
53
53
|
"echarts": "^6.0.0",
|
|
54
54
|
"github-slugger": "^2.0.0",
|
|
55
55
|
"markdown-it-container": "^4.0.0",
|
|
@@ -57,11 +57,8 @@
|
|
|
57
57
|
"markdown-it-mathjax3": "^4.3.2",
|
|
58
58
|
"markdown-it-task-lists": "^2.1.1",
|
|
59
59
|
"marked": "^15.0.11",
|
|
60
|
-
"minisearch": "^7.2.0",
|
|
61
|
-
"markmap-lib": "^0.18.12",
|
|
62
|
-
"markmap-toolbar": "^0.18.12",
|
|
63
|
-
"markmap-view": "^0.18.12",
|
|
64
60
|
"mermaid": "^11.5.0",
|
|
61
|
+
"minisearch": "^7.2.0",
|
|
65
62
|
"sass-embedded": "^1.90.0",
|
|
66
63
|
"swiper": "^11.2.1",
|
|
67
64
|
"uuid": "^11.1.0",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { parseInlineSegments } from '@tnotesjs/mindmap-core'
|
|
2
|
+
import { defineComponent, h } from 'vue'
|
|
3
|
+
|
|
4
|
+
export default defineComponent({
|
|
5
|
+
name: 'MindmapInlineRuns',
|
|
6
|
+
props: {
|
|
7
|
+
raw: { type: String, default: '' },
|
|
8
|
+
},
|
|
9
|
+
setup(props) {
|
|
10
|
+
return () => h('span', { class: 'mindmap-inline-runs' }, parseInlineSegments(props.raw).map((segment) => {
|
|
11
|
+
const classes = Object.entries(segment.marks)
|
|
12
|
+
.filter(([, enabled]) => enabled)
|
|
13
|
+
.map(([name]) => `is-${name}`)
|
|
14
|
+
if (segment.link) {
|
|
15
|
+
return h('a', {
|
|
16
|
+
class: ['mindmap-inline-run', 'is-link', ...classes],
|
|
17
|
+
href: segment.link.url,
|
|
18
|
+
target: '_blank',
|
|
19
|
+
rel: 'noopener noreferrer',
|
|
20
|
+
}, segment.text)
|
|
21
|
+
}
|
|
22
|
+
return h('span', { class: ['mindmap-inline-run', ...classes] }, segment.text)
|
|
23
|
+
}))
|
|
24
|
+
},
|
|
25
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import InlineRuns from './InlineRuns'
|
|
3
|
+
|
|
4
|
+
import type { MindmapNode } from '@tnotesjs/mindmap-core'
|
|
5
|
+
|
|
6
|
+
defineOptions({ name: 'MindmapOutlineNode' })
|
|
7
|
+
|
|
8
|
+
defineProps<{
|
|
9
|
+
node: MindmapNode
|
|
10
|
+
version: number
|
|
11
|
+
root?: boolean
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
defineEmits<{
|
|
15
|
+
toggle: [id: string]
|
|
16
|
+
}>()
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<li class="mindmap-outline-node" :class="{ 'is-root': root, 'is-done': node.content.checked === true }">
|
|
21
|
+
<div class="mindmap-outline-row">
|
|
22
|
+
<button
|
|
23
|
+
v-if="node.children.length > 0"
|
|
24
|
+
type="button"
|
|
25
|
+
class="mindmap-outline-toggle"
|
|
26
|
+
:aria-label="node.collapsed ? '展开主题' : '折叠主题'"
|
|
27
|
+
:aria-expanded="!node.collapsed"
|
|
28
|
+
@click="$emit('toggle', node.id)"
|
|
29
|
+
>
|
|
30
|
+
{{ node.collapsed ? '›' : '⌄' }}
|
|
31
|
+
</button>
|
|
32
|
+
<span v-else class="mindmap-outline-leaf" aria-hidden="true">•</span>
|
|
33
|
+
<input
|
|
34
|
+
v-if="node.content.checked !== null && !root"
|
|
35
|
+
class="mindmap-outline-checkbox"
|
|
36
|
+
type="checkbox"
|
|
37
|
+
:checked="node.content.checked"
|
|
38
|
+
disabled
|
|
39
|
+
aria-label="只读待办状态"
|
|
40
|
+
/>
|
|
41
|
+
<span class="mindmap-outline-label">
|
|
42
|
+
<InlineRuns :raw="node.content.image ? node.content.text : node.content.raw" />
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
<img
|
|
46
|
+
v-if="node.content.image"
|
|
47
|
+
class="mindmap-outline-image"
|
|
48
|
+
:src="node.content.image.src"
|
|
49
|
+
:alt="node.content.image.alt"
|
|
50
|
+
:style="node.content.image.width ? { width: `${node.content.image.width}px` } : undefined"
|
|
51
|
+
/>
|
|
52
|
+
<ul v-if="node.children.length > 0 && !node.collapsed" class="mindmap-outline-children">
|
|
53
|
+
<MindmapOutlineNode
|
|
54
|
+
v-for="child in node.children"
|
|
55
|
+
:key="child.id"
|
|
56
|
+
:node="child"
|
|
57
|
+
:version="version"
|
|
58
|
+
@toggle="$emit('toggle', $event)"
|
|
59
|
+
/>
|
|
60
|
+
</ul>
|
|
61
|
+
</li>
|
|
62
|
+
</template>
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { CanvasViewer, MindmapSession } from '@tnotesjs/mindmap-core'
|
|
3
|
+
import { onContentUpdated, useData } from 'vitepress'
|
|
4
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
|
5
|
+
|
|
6
|
+
import { normalizeMindmapMarkdown } from './compat'
|
|
7
|
+
import { applyInitialExpandLevel } from './expandLevel'
|
|
8
|
+
import MindmapOutlineNode from './MindmapOutlineNode.vue'
|
|
9
|
+
|
|
10
|
+
type PreviewView = 'mindmap' | 'outline' | 'source'
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(defineProps<{
|
|
13
|
+
content?: string
|
|
14
|
+
initialExpandLevel?: number
|
|
15
|
+
}>(), {
|
|
16
|
+
content: '',
|
|
17
|
+
initialExpandLevel: 3,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const { isDark } = useData()
|
|
21
|
+
const activeView = ref<PreviewView>('mindmap')
|
|
22
|
+
const canvasHost = ref<HTMLElement | null>(null)
|
|
23
|
+
const session = shallowRef<MindmapSession | null>(null)
|
|
24
|
+
const renderVersion = ref(0)
|
|
25
|
+
let viewer: CanvasViewer | null = null
|
|
26
|
+
let mounted = false
|
|
27
|
+
|
|
28
|
+
function decodeContent(value: string): string {
|
|
29
|
+
try {
|
|
30
|
+
return decodeURIComponent(value)
|
|
31
|
+
} catch {
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const normalizedContent = computed(() => normalizeMindmapMarkdown(decodeContent(props.content)))
|
|
37
|
+
|
|
38
|
+
function createViewer(): void {
|
|
39
|
+
if (!mounted || !canvasHost.value || !session.value || viewer) return
|
|
40
|
+
viewer = new CanvasViewer(canvasHost.value, session.value, {
|
|
41
|
+
theme: isDark.value ? 'dark' : 'light',
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rebuildSession(): void {
|
|
46
|
+
viewer?.destroy()
|
|
47
|
+
viewer = null
|
|
48
|
+
const next = new MindmapSession({
|
|
49
|
+
markdown: normalizedContent.value,
|
|
50
|
+
fileName: 'mindmap-preview.tn-mindmap.md',
|
|
51
|
+
})
|
|
52
|
+
applyInitialExpandLevel(next, props.initialExpandLevel)
|
|
53
|
+
const invalidate = () => { renderVersion.value += 1 }
|
|
54
|
+
next.on('collapseChange', invalidate)
|
|
55
|
+
next.on('focusChange', invalidate)
|
|
56
|
+
next.on('change', invalidate)
|
|
57
|
+
session.value = next
|
|
58
|
+
renderVersion.value += 1
|
|
59
|
+
void nextTick(createViewer)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function setView(view: PreviewView): void {
|
|
63
|
+
activeView.value = view
|
|
64
|
+
if (view === 'mindmap') void nextTick(() => viewer?.zoomToFit())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toggleNode(id: string): void {
|
|
68
|
+
session.value?.toggleCollapse(id)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function exitFocusTo(index: number): void {
|
|
72
|
+
session.value?.exitFocusTo(index)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
watch([normalizedContent, () => props.initialExpandLevel], rebuildSession, { immediate: true })
|
|
76
|
+
watch(isDark, (dark) => viewer?.setTheme(dark ? 'dark' : 'light'))
|
|
77
|
+
|
|
78
|
+
onMounted(() => {
|
|
79
|
+
mounted = true
|
|
80
|
+
createViewer()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
onContentUpdated(() => {
|
|
84
|
+
if (activeView.value === 'mindmap') void nextTick(() => viewer?.zoomToFit())
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
onBeforeUnmount(() => {
|
|
88
|
+
mounted = false
|
|
89
|
+
viewer?.destroy()
|
|
90
|
+
viewer = null
|
|
91
|
+
})
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<template>
|
|
95
|
+
<section class="mindmap-preview" :class="{ 'is-dark': isDark }">
|
|
96
|
+
<header class="mindmap-preview-header">
|
|
97
|
+
<nav class="mindmap-preview-tabs" aria-label="脑图预览视图">
|
|
98
|
+
<button
|
|
99
|
+
v-for="item in ([['mindmap', '脑图'], ['outline', '大纲'], ['source', '源码']] as const)"
|
|
100
|
+
:key="item[0]"
|
|
101
|
+
type="button"
|
|
102
|
+
:class="{ 'is-active': activeView === item[0] }"
|
|
103
|
+
@click="setView(item[0])"
|
|
104
|
+
>
|
|
105
|
+
{{ item[1] }}
|
|
106
|
+
</button>
|
|
107
|
+
</nav>
|
|
108
|
+
<button v-if="activeView === 'mindmap'" type="button" class="mindmap-fit" @click="viewer?.zoomToFit()">
|
|
109
|
+
适应视口
|
|
110
|
+
</button>
|
|
111
|
+
</header>
|
|
112
|
+
|
|
113
|
+
<nav v-if="session && session.focusPath.length > 0" class="mindmap-focus-path" aria-label="当前主题路径">
|
|
114
|
+
<button type="button" @click="exitFocusTo(0)">全部</button>
|
|
115
|
+
<template v-for="(node, index) in session.focusPath" :key="node.id">
|
|
116
|
+
<span aria-hidden="true">/</span>
|
|
117
|
+
<button type="button" @click="exitFocusTo(index + 1)">{{ node.content.text }}</button>
|
|
118
|
+
</template>
|
|
119
|
+
</nav>
|
|
120
|
+
|
|
121
|
+
<div v-show="activeView === 'mindmap'" ref="canvasHost" class="mindmap-canvas-host" />
|
|
122
|
+
|
|
123
|
+
<div v-if="activeView === 'outline' && session" class="mindmap-outline" :data-version="renderVersion">
|
|
124
|
+
<ul class="mindmap-outline-root">
|
|
125
|
+
<MindmapOutlineNode
|
|
126
|
+
:node="session.focusRootNode"
|
|
127
|
+
:version="renderVersion"
|
|
128
|
+
root
|
|
129
|
+
@toggle="toggleNode"
|
|
130
|
+
/>
|
|
131
|
+
</ul>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<pre v-if="activeView === 'source'" class="mindmap-source"><code>{{ normalizedContent }}</code></pre>
|
|
135
|
+
</section>
|
|
136
|
+
</template>
|
|
137
|
+
|
|
138
|
+
<style scoped lang="scss">
|
|
139
|
+
.mindmap-preview {
|
|
140
|
+
--mindmap-panel: var(--vp-c-bg-soft);
|
|
141
|
+
--mindmap-border: var(--vp-c-divider);
|
|
142
|
+
margin: 1.5rem 0;
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
border: 1px solid var(--mindmap-border);
|
|
145
|
+
border-radius: 10px;
|
|
146
|
+
background: var(--vp-c-bg);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.mindmap-preview-header {
|
|
150
|
+
display: flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
justify-content: space-between;
|
|
153
|
+
min-height: 42px;
|
|
154
|
+
padding: 5px 8px;
|
|
155
|
+
border-bottom: 1px solid var(--mindmap-border);
|
|
156
|
+
background: var(--mindmap-panel);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.mindmap-preview-tabs {
|
|
160
|
+
display: flex;
|
|
161
|
+
gap: 3px;
|
|
162
|
+
|
|
163
|
+
button {
|
|
164
|
+
padding: 5px 12px;
|
|
165
|
+
border-radius: 6px;
|
|
166
|
+
color: var(--vp-c-text-2);
|
|
167
|
+
font-size: 13px;
|
|
168
|
+
font-weight: 600;
|
|
169
|
+
|
|
170
|
+
&:hover,
|
|
171
|
+
&.is-active {
|
|
172
|
+
color: var(--vp-c-text-1);
|
|
173
|
+
background: var(--vp-c-bg);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.mindmap-fit,
|
|
179
|
+
.mindmap-focus-path button {
|
|
180
|
+
color: var(--vp-c-text-2);
|
|
181
|
+
font-size: 12px;
|
|
182
|
+
|
|
183
|
+
&:hover { color: var(--vp-c-brand-1); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.mindmap-focus-path {
|
|
187
|
+
display: flex;
|
|
188
|
+
gap: 6px;
|
|
189
|
+
align-items: center;
|
|
190
|
+
padding: 6px 12px;
|
|
191
|
+
overflow-x: auto;
|
|
192
|
+
border-bottom: 1px solid var(--mindmap-border);
|
|
193
|
+
white-space: nowrap;
|
|
194
|
+
color: var(--vp-c-text-3);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.mindmap-canvas-host {
|
|
198
|
+
position: relative;
|
|
199
|
+
width: 100%;
|
|
200
|
+
height: 440px;
|
|
201
|
+
overflow: hidden;
|
|
202
|
+
background: var(--vp-c-bg);
|
|
203
|
+
touch-action: none;
|
|
204
|
+
user-select: none;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.mindmap-canvas-host:deep(.mm-canvas),
|
|
208
|
+
.mindmap-canvas-host:deep(.mm-overlay) {
|
|
209
|
+
position: absolute;
|
|
210
|
+
inset: 0;
|
|
211
|
+
width: 100%;
|
|
212
|
+
height: 100%;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.mindmap-canvas-host:deep(.mm-overlay) { pointer-events: none; }
|
|
216
|
+
.mindmap-canvas-host:deep(.mm-editor) { outline: none; }
|
|
217
|
+
|
|
218
|
+
.mindmap-outline {
|
|
219
|
+
max-height: 560px;
|
|
220
|
+
padding: 18px 22px 22px;
|
|
221
|
+
overflow: auto;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.mindmap-outline-root,
|
|
225
|
+
.mindmap-outline :deep(ul) {
|
|
226
|
+
margin: 0;
|
|
227
|
+
padding: 0;
|
|
228
|
+
list-style: none;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.mindmap-outline :deep(.mindmap-outline-children) {
|
|
232
|
+
margin-left: 10px;
|
|
233
|
+
padding-left: 19px;
|
|
234
|
+
border-left: 1px solid var(--vp-c-divider);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.mindmap-outline :deep(.mindmap-outline-row) {
|
|
238
|
+
display: flex;
|
|
239
|
+
align-items: flex-start;
|
|
240
|
+
gap: 7px;
|
|
241
|
+
min-height: 30px;
|
|
242
|
+
padding: 3px 0;
|
|
243
|
+
color: var(--vp-c-text-1);
|
|
244
|
+
line-height: 24px;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.mindmap-outline :deep(.mindmap-outline-toggle),
|
|
248
|
+
.mindmap-outline :deep(.mindmap-outline-leaf) {
|
|
249
|
+
flex: 0 0 18px;
|
|
250
|
+
width: 18px;
|
|
251
|
+
color: var(--vp-c-text-3);
|
|
252
|
+
text-align: center;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.mindmap-outline :deep(.mindmap-outline-toggle:hover) { color: var(--vp-c-brand-1); }
|
|
256
|
+
.mindmap-outline :deep(.mindmap-outline-checkbox) { margin-top: 5px; }
|
|
257
|
+
.mindmap-outline :deep(.mindmap-outline-label) { min-width: 0; overflow-wrap: anywhere; }
|
|
258
|
+
.mindmap-outline :deep(.mindmap-outline-node.is-root > .mindmap-outline-row) { font-size: 18px; font-weight: 700; }
|
|
259
|
+
.mindmap-outline :deep(.mindmap-outline-node.is-done > .mindmap-outline-row .mindmap-outline-label) { opacity: .58; text-decoration: line-through; }
|
|
260
|
+
.mindmap-outline :deep(.mindmap-outline-image) { display: block; max-width: min(100%, 560px); max-height: 360px; margin: 5px 0 12px 25px; border-radius: 6px; }
|
|
261
|
+
.mindmap-outline :deep(.is-bold) { font-weight: 700; }
|
|
262
|
+
.mindmap-outline :deep(.is-italic) { font-style: italic; }
|
|
263
|
+
.mindmap-outline :deep(.is-underline) { text-decoration: underline; }
|
|
264
|
+
.mindmap-outline :deep(.is-strike) { text-decoration: line-through; }
|
|
265
|
+
.mindmap-outline :deep(.is-highlight) { padding: 0 2px; border-radius: 2px; background: #ffe56b; color: #252525; }
|
|
266
|
+
.mindmap-outline :deep(.is-code) { padding: 1px 5px; border-radius: 4px; background: var(--vp-c-bg-soft); color: var(--vp-c-danger-1); font-family: var(--vp-font-family-mono); }
|
|
267
|
+
.mindmap-outline :deep(.is-link) { color: var(--vp-c-brand-1); text-decoration: underline; text-underline-offset: 3px; }
|
|
268
|
+
|
|
269
|
+
.mindmap-source {
|
|
270
|
+
max-height: 560px;
|
|
271
|
+
margin: 0;
|
|
272
|
+
padding: 18px 22px;
|
|
273
|
+
overflow: auto;
|
|
274
|
+
border-radius: 0;
|
|
275
|
+
background: var(--vp-code-block-bg);
|
|
276
|
+
color: var(--vp-code-block-color);
|
|
277
|
+
font-size: 13px;
|
|
278
|
+
line-height: 1.65;
|
|
279
|
+
white-space: pre;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
@media (max-width: 768px) {
|
|
283
|
+
.mindmap-canvas-host { height: 360px; }
|
|
284
|
+
.mindmap-preview-tabs button { padding-inline: 9px; }
|
|
285
|
+
.mindmap-outline { padding-inline: 12px; }
|
|
286
|
+
}
|
|
287
|
+
</style>
|