feat: 知识图谱视图改用 markmap(消费文件树渲染 XMind 思维导图,移除 @antv/g6)
This commit is contained in:
@@ -10,7 +10,6 @@
|
|||||||
"typecheck": "tsc -b"
|
"typecheck": "tsc -b"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@antv/g6": "^5.1.1",
|
|
||||||
"@hunter/shared": "workspace:*",
|
"@hunter/shared": "workspace:*",
|
||||||
"@tiptap/core": "^3.30.2",
|
"@tiptap/core": "^3.30.2",
|
||||||
"@tiptap/extension-character-count": "^3.30.2",
|
"@tiptap/extension-character-count": "^3.30.2",
|
||||||
@@ -30,6 +29,7 @@
|
|||||||
"@tiptap/suggestion": "^3.30.2",
|
"@tiptap/suggestion": "^3.30.2",
|
||||||
"hotkeys-js": "^4.0.5",
|
"hotkeys-js": "^4.0.5",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
|
"markmap-view": "^0.18.12",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
|
|||||||
@@ -1,110 +1,125 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Graph } from '@antv/g6';
|
import { Markmap } from 'markmap-view';
|
||||||
import { invoke } from '../../ipc-client';
|
|
||||||
import { IPC, type TreeNode } from '@hunter/shared';
|
|
||||||
import { useStore } from '../../store';
|
import { useStore } from '../../store';
|
||||||
|
import type { TreeNode } from '@hunter/shared';
|
||||||
|
|
||||||
interface TNode { id: string; name: string; type: string; parentId: string | null; }
|
// markmap 的纯数据节点(与 markmap-common IPureNode 结构一致:content/children/payload)
|
||||||
|
type PureNode = {
|
||||||
|
content: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
children: PureNode[];
|
||||||
|
};
|
||||||
|
|
||||||
function truncate(s: string, n: number) { return s.length > n ? s.slice(0, n) + '…' : s; }
|
const HTML_ESCAPES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||||
|
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
|
||||||
|
|
||||||
// 扁平 TreeNode[] → 带 parentId 的节点列表 + edges
|
type NodeType = 'root' | 'folder' | 'doc';
|
||||||
function flatten(nodes: TreeNode[]): { nodes: TNode[]; edges: { source: string; target: string }[] } {
|
|
||||||
const out: TNode[] = []; const edges: { source: string; target: string }[] = [];
|
// 把扁平 TreeNode[](parent 引用)转成 markmap 的树
|
||||||
out.push({ id: '__root', name: '库', type: 'root', parentId: null });
|
function toMarkmapTree(nodes: TreeNode[]): PureNode {
|
||||||
|
const byParent = new Map<string, TreeNode[]>();
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
const pid = n.parent === '' ? '__root' : n.parent;
|
const arr = byParent.get(n.parent) ?? [];
|
||||||
out.push({ id: n.path, name: n.name, type: n.type, parentId: pid });
|
arr.push(n);
|
||||||
edges.push({ source: pid, target: n.path });
|
byParent.set(n.parent, arr);
|
||||||
}
|
}
|
||||||
return { nodes: out, edges };
|
|
||||||
|
const build = (path: string, type: NodeType, name: string): PureNode => {
|
||||||
|
const cls = type === 'root' ? 'mm-root' : type === 'folder' ? 'mm-folder' : 'mm-doc';
|
||||||
|
const icon = type === 'root' ? '🗂' : type === 'folder' ? '📁' : '📄';
|
||||||
|
const kids = type === 'doc' ? [] : (byParent.get(path) ?? []);
|
||||||
|
return {
|
||||||
|
content:
|
||||||
|
'<span class="mm-node ' + cls + '">' +
|
||||||
|
'<span class="mm-ico">' + icon + '</span>' +
|
||||||
|
'<span class="mm-name">' + escapeHtml(name) + '</span>' +
|
||||||
|
'</span>',
|
||||||
|
payload: { type, path, name },
|
||||||
|
children: kids.map((k) => build(k.path, k.type, k.name)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return build('', 'root', '库');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 手动算横向树坐标:深度→x,同父同层→y 均匀分布
|
const MARKMAP_OPTIONS = {
|
||||||
function layoutTree(nodes: TNode[], w: number, h: number): Map<string, { x: number; y: number }> {
|
autoFit: true,
|
||||||
const childrenOf = new Map<string, TNode[]>();
|
duration: 300,
|
||||||
nodes.forEach(n => { const a = childrenOf.get(n.parentId ?? '__null') ?? []; a.push(n); childrenOf.set(n.parentId ?? '__null', a); });
|
initialExpandLevel: 2,
|
||||||
const pos = new Map<string, { x: number; y: number }>();
|
maxWidth: 320,
|
||||||
const maxDepth = Math.max(1, ...nodes.map(n => depth(n, nodes)));
|
paddingX: 12,
|
||||||
const xStep = (w - 120) / maxDepth;
|
// 颜色:根/文件夹为蓝色系,文档为灰,避免默认彩虹色干扰阅读
|
||||||
// 计算每个节点子树叶子数,用于纵向分配空间
|
color: (node: { payload?: Record<string, unknown> }) => {
|
||||||
const leafCount = (id: string): number => {
|
const t = node.payload?.type as NodeType | undefined;
|
||||||
const kids = childrenOf.get(id) ?? [];
|
if (t === 'doc') return '#9aa0aa';
|
||||||
if (kids.length === 0) return 1;
|
if (t === 'folder') return '#5db0ff';
|
||||||
return kids.reduce((s, k) => s + leafCount(k.id), 0);
|
return '#4a9eff';
|
||||||
};
|
},
|
||||||
const place = (id: string, depthNum: number, yStart: number, ySpan: number) => {
|
};
|
||||||
const kids = childrenOf.get(id) ?? [];
|
|
||||||
pos.set(id, { x: 60 + depthNum * xStep, y: yStart + ySpan / 2 });
|
|
||||||
let accY = yStart;
|
|
||||||
for (const k of kids) {
|
|
||||||
const lc = leafCount(k.id);
|
|
||||||
place(k.id, depthNum + 1, accY, lc * (ySpan / Math.max(leafCount(id), 1)));
|
|
||||||
accY += lc * (ySpan / Math.max(leafCount(id), 1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
place('__root', 0, 40, h - 80);
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
function depth(n: TNode, all: TNode[]): number {
|
|
||||||
if (!n.parentId || n.parentId === '__root') return n.parentId === '__root' ? 1 : 0;
|
|
||||||
const p = all.find(x => x.id === n.parentId);
|
|
||||||
return p ? depth(p, all) + 1 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GraphView() {
|
export function GraphView() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
const { openDoc } = useStore();
|
const mmRef = useRef<Markmap | null>(null);
|
||||||
|
const { tree, openDoc } = useStore();
|
||||||
|
|
||||||
|
// 建一次 markmap 实例 + 点击委托
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
const svg = svgRef.current;
|
||||||
const el = containerRef.current;
|
if (!svg) return;
|
||||||
let g: Graph;
|
|
||||||
try {
|
|
||||||
g = new Graph({
|
|
||||||
container: el,
|
|
||||||
width: 1200, height: 700,
|
|
||||||
autoResize: true,
|
|
||||||
node: {
|
|
||||||
style: {
|
|
||||||
size: (d: any) => [130, 30] as any,
|
|
||||||
fill: (d: any) => (d.style && d.style.fill) || '#3a3a4a',
|
|
||||||
stroke: (d: any) => (d.style && d.style.stroke) || '#5a5a6a',
|
|
||||||
lineWidth: 1.5, radius: 6,
|
|
||||||
labelText: (d: any) => truncate((d.data && d.data.title) || d.id, 14),
|
|
||||||
labelFill: '#eee', labelFontSize: 12, labelPlacement: 'center',
|
|
||||||
}
|
|
||||||
} as any,
|
|
||||||
edge: { style: { stroke: '#4a5a7a', lineWidth: 1.5, type: 'cubic' } } as any,
|
|
||||||
behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element'],
|
|
||||||
});
|
|
||||||
} catch (e) { console.error(e); return; }
|
|
||||||
|
|
||||||
invoke(IPC.treeList, undefined as never).then((tree: TreeNode[]) => {
|
const mm = new Markmap(svg, MARKMAP_OPTIONS);
|
||||||
const { nodes: flat, edges } = flatten(tree);
|
mmRef.current = mm;
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const w = rect.width || 1200, h = rect.height || 700;
|
|
||||||
const pos = layoutTree(flat, w, h);
|
|
||||||
const gNodes = flat.map(n => ({
|
|
||||||
id: n.id,
|
|
||||||
x: pos.get(n.id)?.x ?? w / 2,
|
|
||||||
y: pos.get(n.id)?.y ?? h / 2,
|
|
||||||
style: { fill: n.type === 'root' ? '#1a3a5a' : n.type === 'folder' ? '#2a4a6a' : '#3a3a4a', stroke: n.type === 'root' ? '#4a9eff' : n.type === 'folder' ? '#4a9eff' : '#5a5a6a' },
|
|
||||||
data: { title: n.name, type: n.type },
|
|
||||||
}));
|
|
||||||
g.setData({ nodes: gNodes, edges } as any);
|
|
||||||
g.render();
|
|
||||||
setTimeout(() => { try { g.fitView(60); } catch {} }, 500);
|
|
||||||
}).catch(e => console.error(e));
|
|
||||||
|
|
||||||
g.on('node:click', (e: any) => { if (e.target && e.target.id && !e.target.id.startsWith('__')) openDoc(e.target.id); });
|
// 点击文档节点 → 打开;点击文件夹文字 → 折叠/展开(与圆点行为一致)
|
||||||
return () => { try { g.destroy(); } catch {} };
|
const onClick = (e: MouseEvent) => {
|
||||||
}, []);
|
const target = e.target as Element | null;
|
||||||
|
if (!target) return;
|
||||||
|
// 折叠圆点交给 markmap 自身处理,避免双重 toggle
|
||||||
|
if ((target as SVGElement).tagName === 'circle') return;
|
||||||
|
const nodeEl = (target as HTMLElement).closest?.('.mm-node') as Element | null;
|
||||||
|
if (!nodeEl) return;
|
||||||
|
let el: Element | null = nodeEl;
|
||||||
|
while (el && !(el as { __data__?: unknown }).__data__) el = el.parentElement;
|
||||||
|
const d = (el as { __data__?: { payload?: Record<string, unknown> } } | null)?.__data__;
|
||||||
|
if (!d) return;
|
||||||
|
const p = d.payload;
|
||||||
|
if (p?.type === 'doc' && typeof p.path === 'string' && p.path) {
|
||||||
|
openDoc(p.path);
|
||||||
|
} else if (p?.type === 'folder' || p?.type === 'root') {
|
||||||
|
mm.toggleNode(d as never).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
svg.addEventListener('click', onClick, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
svg.removeEventListener('click', onClick, true);
|
||||||
|
try { mm.destroy(); } catch {}
|
||||||
|
mmRef.current = null;
|
||||||
|
};
|
||||||
|
}, [openDoc]);
|
||||||
|
|
||||||
|
// tree 变化时刷新数据(新建/删除/重命名后自动更新,无需重新进入图谱视图)
|
||||||
|
useEffect(() => {
|
||||||
|
const mm = mmRef.current;
|
||||||
|
if (!mm || tree.length === 0) return;
|
||||||
|
mm.setData(toMarkmapTree(tree) as never).catch(() => {});
|
||||||
|
}, [tree]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, background: '#111', position: 'relative', overflow: 'hidden' }}>
|
<div className="markmap-dark" style={{ flex: 1, background: '#111', position: 'relative', overflow: 'hidden' }}>
|
||||||
<div ref={containerRef} style={{ position: 'absolute', inset: 0 }} />
|
<svg ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
|
||||||
|
<style>{`
|
||||||
|
.markmap-foreign { color: #ddd; font-size: 13px; line-height: 1.2; }
|
||||||
|
.mm-node { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||||
|
.mm-ico { font-size: 13px; opacity: 0.85; }
|
||||||
|
.mm-name { font-weight: 500; }
|
||||||
|
.mm-root .mm-name { font-weight: 700; color: #cfe4ff; }
|
||||||
|
.mm-folder .mm-name { font-weight: 600; color: #cfe4ff; }
|
||||||
|
.mm-doc .mm-name { color: #9aa0aa; font-weight: 400; }
|
||||||
|
.mm-doc { cursor: pointer; }
|
||||||
|
.mm-doc:hover .mm-name { color: #fff; text-decoration: underline; }
|
||||||
|
.mm-folder, .mm-root { cursor: pointer; }
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+169
-507
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user