From 52474012c02dadb8b0bfc2fdbe0597c3ccd4ec9d Mon Sep 17 00:00:00 2001 From: hunter-dev Date: Wed, 26 Aug 2026 15:40:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(M7):=20FR-G1/G4/G5=20=E5=9B=BE=E8=B0=B1?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E8=A1=A5=E5=85=A8=E2=80=94=E2=80=94=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E5=8A=A0=E6=96=87=E6=A1=A3=E5=9B=BE=E6=A0=87+?= =?UTF-8?q?=E5=BA=93=E5=86=85=E8=B7=AF=E5=BE=84=E5=89=AF=E6=96=87=E6=9C=AC?= =?UTF-8?q?(G1);=20scaleExtent=E6=94=B90.1-2+=E8=83=8C=E6=99=AFrect?= =?UTF-8?q?=E7=BB=91zoom=E4=B8=8D=E6=8B=A6=E6=88=AA=E7=82=B9=E5=87=BB+?= =?UTF-8?q?=E9=80=82=E9=85=8D=E8=A7=86=E5=9B=BE+=E7=BC=A9=E6=94=BE?= =?UTF-8?q?=E6=8E=A7=E4=BB=B6(G4);=20=E7=82=B9=E5=87=BB=E5=8D=A1=E7=89=87o?= =?UTF-8?q?penDoc(d.id)+=E5=88=87=E5=9B=9E=E7=BC=96=E8=BE=91=E8=A7=86?= =?UTF-8?q?=E5=9B=BE(G5);=20graph=20E2E=203=20=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/renderer/App.tsx | 2 +- .../src/renderer/features/graph/GraphView.tsx | 4 +- .../src/renderer/features/graph/LinkGraph.tsx | 88 +++++++++++++------ apps/desktop/tests/e2e/graph.spec.ts | 59 ++++++++++++- 4 files changed, 124 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index ca6309b..4456374 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -68,7 +68,7 @@ export function App() { layout.setSidebarWidth(layout.sidebarWidth + d)} /> )} - {view === 'editor' ? : } + {view === 'editor' ? : setView('editor')} />} {view === 'editor' && layout.outlineVisible && (<> layout.setOutlineWidth(layout.outlineWidth + d)} />
diff --git a/apps/desktop/src/renderer/features/graph/GraphView.tsx b/apps/desktop/src/renderer/features/graph/GraphView.tsx index 5189cc9..90cd19d 100644 --- a/apps/desktop/src/renderer/features/graph/GraphView.tsx +++ b/apps/desktop/src/renderer/features/graph/GraphView.tsx @@ -114,7 +114,7 @@ function tabBtn(active: boolean): React.CSSProperties { return { padding: '3px 10px', fontSize: 12, background: active ? 'var(--control-border)' : 'transparent', color: active ? '#fff' : 'var(--text-tertiary)', border: '1px solid var(--control-border)', borderRadius: 4, cursor: 'pointer' }; } -export function GraphView() { +export function GraphView({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) { const [mode, setMode] = useState<'mindmap' | 'graph'>('mindmap'); return (
@@ -122,7 +122,7 @@ export function GraphView() {
- {mode === 'mindmap' ? : } + {mode === 'mindmap' ? : }
); } \ No newline at end of file diff --git a/apps/desktop/src/renderer/features/graph/LinkGraph.tsx b/apps/desktop/src/renderer/features/graph/LinkGraph.tsx index 9e03656..f8c56c6 100644 --- a/apps/desktop/src/renderer/features/graph/LinkGraph.tsx +++ b/apps/desktop/src/renderer/features/graph/LinkGraph.tsx @@ -2,24 +2,22 @@ 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); +const CARD_W = 170, CARD_H = 46; +const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '...' : s); -export function LinkGraph() { +export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) { const svgRef = useRef(null); const { openDoc } = useStore(); const [data, setData] = useState(null); const [err, setErr] = useState(null); + const zoomRef = useRef<{ zoomBeh: any; svg: any; g: any } | null>(null); - // 拉取图数据 useEffect(() => { let alive = true; (async () => { @@ -33,7 +31,6 @@ export function LinkGraph() { return () => { alive = false; }; }, []); - // 建模拟 + 渲染 useEffect(() => { if (!data || !svgRef.current) return; const svgEl = svgRef.current; @@ -42,9 +39,11 @@ export function LinkGraph() { 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); + // FR-G4:scaleExtent [0.1, 2];zoom 绑定在背景 rect 上,不拦截卡片点击 + const zoomBeh = zoom().scaleExtent([0.1, 2]).on('zoom', (ev: any) => g.attr('transform', ev.transform)); + const bgRect = svg.append('rect').attr('width', W).attr('height', H).attr('fill', 'transparent').attr('pointer-events', 'all'); + (bgRect as any).call(zoomBeh); + zoomRef.current = { zoomBeh, svg, g }; const n = Math.max(1, data.nodes.length); const nodes: SimNode[] = data.nodes.map((node, i) => ({ @@ -60,42 +59,81 @@ export function LinkGraph() { const linkSel = g.append('g').attr('stroke', 'var(--control-border)').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)); + .force('collide', forceCollide(95)); - 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); + const nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer'); - // 卡片:外框(描边=分组色)+ 左侧色条 + 标题 + 星标 + // FR-G1:卡片:外框 + 色条 + 文档图标 + 标题 + 库内路径副文本 + 星标 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', 'var(--bg-secondary)').attr('stroke', (d) => d.color || 'var(--control-border)').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', 'var(--text-primary)').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', 'var(--accent)').attr('font-size', 12).text('★'); + // FR-G1:文档图标 + nodeSel.append('text').attr('x', -CARD_W / 2 + 12).attr('y', 0).attr('font-size', 12).attr('fill', 'var(--text-tertiary)').text('📄'); + // 标题 + nodeSel.append('text').attr('x', -CARD_W / 2 + 28).attr('y', -2).attr('fill', 'var(--text-primary)').attr('font-size', 12).attr('font-family', 'sans-serif') + .text((d) => truncate(d.title, 14)); + // FR-G1:库内路径副文本 + nodeSel.append('text').attr('x', -CARD_W / 2 + 28).attr('y', 12).attr('fill', 'var(--text-tertiary)').attr('font-size', 9).attr('font-family', 'sans-serif') + .text((d) => truncate(d.basePath || d.id, 18)); + // 星标 + nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 2).attr('fill', 'var(--accent)').attr('font-size', 12).text('*'); - nodeSel.on('click', (_ev: any, d: any) => { if (d.basePath) openDoc(d.basePath); }); + // FR-G5:点击卡片打开文档并切回编辑器视图 + // d.id 是文档路径(如 'g5doc.md'),d.basePath 是所在目录(根目录为空) + nodeSel.attr('data-doc-path', (d: any) => d.id || ''); + nodeSel.each(function(d: any) { + const el = this as HTMLElement; + el.addEventListener('click', (ev: MouseEvent) => { + ev.stopPropagation(); + if (d.id) { openDoc(d.id); onSwitchToEditor?.(); } + }); + }); 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 () => { sim.stop(); (bgRect as any).on('.zoom', null); }; + }, [data, openDoc, onSwitchToEditor]); + + // FR-G4:适配视图 + 缩放控制 + const fitView = () => { + if (!zoomRef.current || !data || data.nodes.length === 0) return; + const { svg, zoomBeh } = zoomRef.current; + const svgEl = svgRef.current!; + const W = svgEl.clientWidth || 800, H = svgEl.clientHeight || 600; + const xs = data.nodes.map((n: any) => n.x || 0), ys = data.nodes.map((n: any) => n.y || 0); + const minX = Math.min(...xs) - CARD_W / 2, maxX = Math.max(...xs) + CARD_W / 2; + const minY = Math.min(...ys) - CARD_H / 2, maxY = Math.max(...ys) + CARD_H / 2; + const dx = maxX - minX, dy = maxY - minY; + const scale = Math.min(2, Math.max(0.1, Math.min(W / Math.max(dx, 1), H / Math.max(dy, 1)) * 0.9)); + const tx = W / 2 - scale * (minX + dx / 2), ty = H / 2 - scale * (minY + dy / 2); + (svg as any).transition().duration(400).call(zoomBeh.transform, (window as any).d3?.zoomIdentity?.translate(tx, ty).scale(scale) || { k: scale, x: tx, y: ty }); + }; + const zoomBy = (factor: number) => { + if (!zoomRef.current) return; + const { svg, zoomBeh } = zoomRef.current; + zoomBeh.scaleBy((svg as any).transition().duration(200), factor); + }; return (
+ {/* FR-G4:画布控件 */} +
+ + + +
{err &&
图谱加载失败:{err}
} {data && data.nodes.length === 0 &&
暂无文档(先建几篇笔记)
}
); -} \ No newline at end of file +} + +const ctrlBtn: React.CSSProperties = { width: 32, height: 28, border: '1px solid var(--control-border)', background: 'var(--bg-secondary)', color: 'var(--text-secondary)', cursor: 'pointer', borderRadius: 4, fontSize: 11 }; diff --git a/apps/desktop/tests/e2e/graph.spec.ts b/apps/desktop/tests/e2e/graph.spec.ts index 2e12ee2..d366f4e 100644 --- a/apps/desktop/tests/e2e/graph.spec.ts +++ b/apps/desktop/tests/e2e/graph.spec.ts @@ -37,6 +37,63 @@ test('知识图谱:卡片+连线(消费 IPC.graph)', async () => { // 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 expect(a.page.locator('svg rect')).toHaveCount(5, { timeout: 10000 }); + await a.app.close(); +}); + +// FR-G4:画布控件——适配视图 + 缩放控件可见 +test('FR-G4:画布控件可见 + 适配视图', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-g4-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await a.invoke('file:create', { basePath: '', name: 'x', isFolder: false }); + await a.invoke('file:create', { basePath: '', name: 'y', isFolder: false }); + await a.invoke('doc:write', { path: 'x.md', meta: { title: 'X篇', links: ['y.md'] }, body: '# X' }); + await a.invoke('doc:write', { path: 'y.md', meta: { title: 'Y篇' }, body: '# Y' }); + await a.page.getByTitle('切换 编辑器/图谱').click(); + await a.page.getByRole('button', { name: '知识图谱' }).click(); + await a.page.waitForTimeout(1000); + // 控件应可见 + await expect(a.page.getByTestId('graph-controls')).toBeVisible({ timeout: 10000 }); + // 点击适配视图不应报错 + await a.page.getByTestId('fit-view-btn').click({ force: true }); + await a.page.waitForTimeout(500); + await a.app.close(); +}); + +// FR-G5:点击卡片切回编辑视图 +test('FR-G5:点击卡片切回编辑视图', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-g5-')); + const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v'); + await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true }); + const a = await launch(ud); + await a.invoke('vault:switch', { root: v }); + await a.invoke('file:create', { basePath: '', name: 'g5doc', isFolder: false }); + await a.invoke('doc:write', { path: 'g5doc.md', meta: { title: 'G5doc' }, body: '# G5' }); + await a.page.getByTitle('切换 编辑器/图谱').click(); + await a.page.getByRole('button', { name: '知识图谱' }).click(); + await a.page.waitForTimeout(3000); + // FR-G5:点击卡片切回编辑视图——直接用 evaluate dispatch click + const result = await a.page.evaluate(() => { + const texts = document.querySelectorAll('svg text'); + for (const t of texts) { + if (t.textContent && t.textContent.includes('G5doc')) { + const g = t.closest('g') as Element | null; + if (g) { g.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); return true; } + } + } + return false; + }); + expect(result).toBe(true); + await a.page.waitForTimeout(1000); + // 验证 openDoc 被调用——store currentPath 应改变 + const storeState = await a.page.evaluate(() => { + const s = (window as any).__STORE__; + return s ? { currentPath: s.getState().currentPath } : null; + }); + expect(storeState?.currentPath).toContain('g5doc'); + await expect(a.page.locator('.ProseMirror')).toBeVisible({ timeout: 5000 }); await a.app.close(); });