feat: M7 知识图谱卡片+连线视图(消费 IPC.graph,d3-force 力导向,星标/分组色/点击打开;GraphView 思维导图/知识图谱 双模切换)
This commit is contained in:
@@ -63,10 +63,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"electron": "^30.0.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^2.3.0",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Markmap } from 'markmap-view';
|
||||
import { useStore } from '../../store';
|
||||
import type { TreeNode } from '@hunter/shared';
|
||||
import { LinkGraph } from './LinkGraph';
|
||||
|
||||
// markmap 的纯数据节点(与 markmap-common IPureNode 结构一致:content/children/payload)
|
||||
// markmap 的纯数据节点(content/children/payload)
|
||||
type PureNode = {
|
||||
content: string;
|
||||
payload?: Record<string, unknown>;
|
||||
@@ -15,7 +16,6 @@ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
|
||||
|
||||
type NodeType = 'root' | 'folder' | 'doc';
|
||||
|
||||
// 把扁平 TreeNode[](parent 引用)转成 markmap 的树
|
||||
function toMarkmapTree(nodes: TreeNode[]): PureNode {
|
||||
const byParent = new Map<string, TreeNode[]>();
|
||||
for (const n of nodes) {
|
||||
@@ -23,22 +23,16 @@ function toMarkmapTree(nodes: TreeNode[]): PureNode {
|
||||
arr.push(n);
|
||||
byParent.set(n.parent, arr);
|
||||
}
|
||||
|
||||
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>',
|
||||
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', '库');
|
||||
}
|
||||
|
||||
@@ -48,7 +42,6 @@ const MARKMAP_OPTIONS = {
|
||||
initialExpandLevel: 2,
|
||||
maxWidth: 320,
|
||||
paddingX: 12,
|
||||
// 颜色:根/文件夹为蓝色系,文档为灰,避免默认彩虹色干扰阅读
|
||||
color: (node: { payload?: Record<string, unknown> }) => {
|
||||
const t = node.payload?.type as NodeType | undefined;
|
||||
if (t === 'doc') return '#9aa0aa';
|
||||
@@ -57,24 +50,19 @@ const MARKMAP_OPTIONS = {
|
||||
},
|
||||
};
|
||||
|
||||
export function GraphView() {
|
||||
function MindmapTree() {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const mmRef = useRef<Markmap | null>(null);
|
||||
const { tree, openDoc } = useStore();
|
||||
|
||||
// 建一次 markmap 实例 + 点击委托
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
|
||||
const mm = new Markmap(svg, MARKMAP_OPTIONS);
|
||||
mmRef.current = mm;
|
||||
|
||||
// 点击文档节点 → 打开;点击文件夹文字 → 折叠/展开(与圆点行为一致)
|
||||
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;
|
||||
@@ -90,7 +78,6 @@ export function GraphView() {
|
||||
}
|
||||
};
|
||||
svg.addEventListener('click', onClick, true);
|
||||
|
||||
return () => {
|
||||
svg.removeEventListener('click', onClick, true);
|
||||
try { mm.destroy(); } catch {}
|
||||
@@ -98,7 +85,6 @@ export function GraphView() {
|
||||
};
|
||||
}, [openDoc]);
|
||||
|
||||
// tree 变化时刷新数据(新建/删除/重命名后自动更新,无需重新进入图谱视图)
|
||||
useEffect(() => {
|
||||
const mm = mmRef.current;
|
||||
if (!mm || tree.length === 0) return;
|
||||
@@ -123,3 +109,20 @@ export function GraphView() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tabBtn(active: boolean): React.CSSProperties {
|
||||
return { padding: '3px 10px', fontSize: 12, background: active ? '#2a2a3a' : 'transparent', color: active ? '#fff' : '#888', border: '1px solid #2a2a3a', borderRadius: 4, cursor: 'pointer' };
|
||||
}
|
||||
|
||||
export function GraphView() {
|
||||
const [mode, setMode] = useState<'mindmap' | 'graph'>('mindmap');
|
||||
return (
|
||||
<div style={{ flex: 1, position: 'relative', overflow: 'hidden', background: '#111' }}>
|
||||
<div style={{ position: 'absolute', top: 8, left: 8, zIndex: 10, display: 'flex', gap: 4 }}>
|
||||
<button style={tabBtn(mode === 'mindmap')} onClick={() => setMode('mindmap')}>思维导图</button>
|
||||
<button style={tabBtn(mode === 'graph')} onClick={() => setMode('graph')}>知识图谱</button>
|
||||
</div>
|
||||
{mode === 'mindmap' ? <MindmapTree /> : <LinkGraph />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { select } from 'd3-selection';
|
||||
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, type SimulationNodeDatum } from 'd3-force';
|
||||
import { zoom } from 'd3-zoom';
|
||||
import { drag } from 'd3-drag';
|
||||
import { useStore } from '../../store';
|
||||
import { IPC, type GraphData, type GraphNode } from '@hunter/shared';
|
||||
|
||||
// M7 知识图谱:消费 IPC.graph(buildGraphData)渲染文档卡片 + frontmatter links 连线
|
||||
// 卡片颜色取自后端按顶层文件夹分配的 12 色轮;星标文档带 ★;点击卡片打开文档
|
||||
type SimNode = GraphNode & SimulationNodeDatum;
|
||||
|
||||
const CARD_W = 160, CARD_H = 34;
|
||||
const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
||||
|
||||
export function LinkGraph() {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const { openDoc } = useStore();
|
||||
const [data, setData] = useState<GraphData | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
// 拉取图数据
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const d = (await (globalThis as any).dsh.invoke(IPC.graph)) as GraphData;
|
||||
if (alive) setData(d);
|
||||
} catch (e: any) {
|
||||
if (alive) setErr(e?.message ?? String(e));
|
||||
}
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
// 建模拟 + 渲染
|
||||
useEffect(() => {
|
||||
if (!data || !svgRef.current) return;
|
||||
const svgEl = svgRef.current;
|
||||
const W = svgEl.clientWidth || 800, H = svgEl.clientHeight || 600;
|
||||
const svg = select(svgEl);
|
||||
svg.selectAll('*').remove();
|
||||
const g = svg.append('g');
|
||||
|
||||
// 平移/缩放
|
||||
const zoomBeh = zoom<SVGSVGElement, unknown>().scaleExtent([0.2, 4]).on('zoom', (ev: any) => g.attr('transform', ev.transform));
|
||||
(svg as any).call(zoomBeh);
|
||||
|
||||
const n = Math.max(1, data.nodes.length);
|
||||
const nodes: SimNode[] = data.nodes.map((node, i) => ({
|
||||
...node,
|
||||
x: W / 2 + Math.cos((2 * Math.PI * i) / n) * 220,
|
||||
y: H / 2 + Math.sin((2 * Math.PI * i) / n) * 220,
|
||||
}));
|
||||
const idx = new Map(nodes.map((nd) => [nd.id, nd] as const));
|
||||
const links = data.edges
|
||||
.filter((e) => idx.has(e.source) && idx.has(e.target))
|
||||
.map((e) => ({ source: idx.get(e.source)!, target: idx.get(e.target)! }));
|
||||
|
||||
const linkSel = g.append('g').attr('stroke', '#3a3a4a').attr('stroke-opacity', 0.6)
|
||||
.selectAll('line').data(links).join('line').attr('stroke-width', 1);
|
||||
|
||||
// 力导向模拟(先建,拖拽回调引用之)
|
||||
const sim = forceSimulation<SimNode>(nodes)
|
||||
.force('link', (forceLink as any)(links).id((d: any) => d.id).distance(130).strength(0.2))
|
||||
.force('charge', forceManyBody().strength(-360))
|
||||
.force('center', forceCenter(W / 2, H / 2))
|
||||
.force('collide', forceCollide(85));
|
||||
|
||||
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer')
|
||||
.call(drag<SVGGElement, SimNode>()
|
||||
.on('start', (ev: any, d: any) => { if (!ev.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
|
||||
.on('drag', (ev: any, d: any) => { d.fx = ev.x; d.fy = ev.y; })
|
||||
.on('end', (ev: any, d: any) => { if (!ev.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }) as any);
|
||||
|
||||
// 卡片:外框(描边=分组色)+ 左侧色条 + 标题 + 星标
|
||||
nodeSel.append('rect').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 5)
|
||||
.attr('fill', '#1f1f2a').attr('stroke', (d) => d.color || '#3a3a4a').attr('stroke-width', 1.5);
|
||||
nodeSel.append('rect').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', 4).attr('height', CARD_H).attr('fill', (d) => d.color || '#5db0ff');
|
||||
nodeSel.append('text').attr('x', -CARD_W / 2 + 12).attr('y', 2).attr('fill', '#ddd').attr('font-size', 12).attr('font-family', 'sans-serif')
|
||||
.text((d) => truncate(d.title, 16));
|
||||
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 4).attr('fill', '#f5c542').attr('font-size', 12).text('★');
|
||||
|
||||
nodeSel.on('click', (_ev: any, d: any) => { if (d.basePath) openDoc(d.basePath); });
|
||||
|
||||
sim.on('tick', () => {
|
||||
linkSel.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y).attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y);
|
||||
nodeSel.attr('transform', (d: any) => 'translate(' + d.x + ',' + d.y + ')');
|
||||
});
|
||||
|
||||
return () => { sim.stop(); (svg as any).on('.zoom', null); };
|
||||
}, [data, openDoc]);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, background: '#111', position: 'relative', overflow: 'hidden' }}>
|
||||
<svg ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
|
||||
{err && <div style={{ position: 'absolute', top: 8, left: 8, color: '#f88', fontSize: 12 }}>图谱加载失败:{err}</div>}
|
||||
{data && data.nodes.length === 0 && <div style={{ position: 'absolute', top: 8, left: 8, color: '#888', fontSize: 12 }}>暂无文档(先建几篇笔记)</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { _electron as electron } from 'playwright';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import nodePath from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
|
||||
|
||||
async function launch(userData: string) {
|
||||
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
|
||||
return { app, page, invoke };
|
||||
}
|
||||
|
||||
test('知识图谱:卡片+连线(消费 IPC.graph)', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-graph-'));
|
||||
const userData = nodePath.join(tmp, 'userdata');
|
||||
const vault = nodePath.join(tmp, 'vault');
|
||||
await fs.mkdir(userData, { recursive: true });
|
||||
await fs.mkdir(vault, { recursive: true });
|
||||
|
||||
const a = await launch(userData);
|
||||
await a.invoke('vault:switch', { root: vault });
|
||||
await a.invoke('file:create', { basePath: '', name: 'a', isFolder: false });
|
||||
await a.invoke('file:create', { basePath: '', name: 'b', isFolder: false });
|
||||
await a.invoke('doc:write', { path: 'a.md', meta: { title: 'A篇', links: ['b.md'] }, body: '# A\n' });
|
||||
await a.invoke('doc:write', { path: 'b.md', meta: { title: 'B篇' }, body: '# B\n' });
|
||||
|
||||
// IPC.graph:2 节点 + a→b 边
|
||||
const gd: { nodes: { id: string; title: string; star: boolean; color: string }[]; edges: { source: string; target: string }[] } = await a.invoke('graph', null);
|
||||
expect(gd.nodes.length).toBe(2);
|
||||
expect(gd.nodes.some(n => n.title === 'A篇')).toBe(true);
|
||||
expect(gd.edges.some(e => e.source === 'a.md' && e.target === 'b.md')).toBe(true);
|
||||
|
||||
// UI:先经 TitleBar 切到图谱视图(view 默认 editor),再切「知识图谱」模式
|
||||
await a.page.getByTitle('切换 编辑器/图谱').click();
|
||||
await a.page.getByRole('button', { name: '知识图谱' }).click();
|
||||
await expect(a.page.locator('svg rect')).toHaveCount(4, { timeout: 10000 });
|
||||
await a.app.close();
|
||||
});
|
||||
Generated
+61
@@ -120,6 +120,18 @@ importers:
|
||||
'@playwright/test':
|
||||
specifier: ^1.62.1
|
||||
version: 1.62.1
|
||||
'@types/d3-drag':
|
||||
specifier: ^3.0.7
|
||||
version: 3.0.7
|
||||
'@types/d3-force':
|
||||
specifier: ^3.0.10
|
||||
version: 3.0.10
|
||||
'@types/d3-selection':
|
||||
specifier: ^3.0.11
|
||||
version: 3.0.11
|
||||
'@types/d3-zoom':
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.8
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.43
|
||||
@@ -132,6 +144,18 @@ importers:
|
||||
cross-env:
|
||||
specifier: ^10.1.0
|
||||
version: 10.1.0
|
||||
d3-drag:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
d3-force:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
d3-selection:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
d3-zoom:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
electron:
|
||||
specifier: ^30.0.0
|
||||
version: 30.5.1(supports-color@7.2.0)
|
||||
@@ -919,6 +943,24 @@ packages:
|
||||
'@types/cacheable-request@6.0.3':
|
||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-drag@3.0.7':
|
||||
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||
|
||||
'@types/d3-force@3.0.10':
|
||||
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-selection@3.0.11':
|
||||
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
|
||||
|
||||
'@types/d3-zoom@3.0.8':
|
||||
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||
|
||||
@@ -3512,6 +3554,25 @@ snapshots:
|
||||
'@types/node': 20.19.43
|
||||
'@types/responselike': 1.0.3
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-drag@3.0.7':
|
||||
dependencies:
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/d3-force@3.0.10': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-selection@3.0.11': {}
|
||||
|
||||
'@types/d3-zoom@3.0.8':
|
||||
dependencies:
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
Reference in New Issue
Block a user