diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 39efbe0..d5a22f6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/renderer/features/graph/GraphView.tsx b/apps/desktop/src/renderer/features/graph/GraphView.tsx index ea6248e..19ad424 100644 --- a/apps/desktop/src/renderer/features/graph/GraphView.tsx +++ b/apps/desktop/src/renderer/features/graph/GraphView.tsx @@ -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; @@ -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(); 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: - '' + - '' + icon + '' + - '' + escapeHtml(name) + '' + - '', + content: '' + icon + '' + escapeHtml(name) + '', 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 }) => { 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(null); const mmRef = useRef(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() { ); } + +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 ( +
+
+ + +
+ {mode === 'mindmap' ? : } +
+ ); +} diff --git a/apps/desktop/src/renderer/features/graph/LinkGraph.tsx b/apps/desktop/src/renderer/features/graph/LinkGraph.tsx new file mode 100644 index 0000000..a0454a9 --- /dev/null +++ b/apps/desktop/src/renderer/features/graph/LinkGraph.tsx @@ -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(null); + const { openDoc } = useStore(); + const [data, setData] = useState(null); + const [err, setErr] = useState(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().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(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() + .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 ( +
+ + {err &&
图谱加载失败:{err}
} + {data && data.nodes.length === 0 &&
暂无文档(先建几篇笔记)
} +
+ ); +} diff --git a/apps/desktop/tests/e2e/graph.spec.ts b/apps/desktop/tests/e2e/graph.spec.ts new file mode 100644 index 0000000..2e12ee2 --- /dev/null +++ b/apps/desktop/tests/e2e/graph.spec.ts @@ -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(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67c0c5a..7ad63be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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