diff --git a/apps/desktop/src/renderer/features/graph/GraphView.tsx b/apps/desktop/src/renderer/features/graph/GraphView.tsx index 92a9d31..aa639c9 100644 --- a/apps/desktop/src/renderer/features/graph/GraphView.tsx +++ b/apps/desktop/src/renderer/features/graph/GraphView.tsx @@ -16,6 +16,9 @@ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]); type NodeType = 'root' | 'folder' | 'doc'; +// 节点图标:根=库 / 文件夹=文件夹 / 文档=文件 +const ICONS: Record = { root: '🗂', folder: '📁', doc: '📄' }; + function toMarkmapTree(nodes: TreeNode[]): PureNode { const byParent = new Map(); for (const n of nodes) { @@ -24,12 +27,12 @@ function toMarkmapTree(nodes: TreeNode[]): PureNode { 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) ?? []); + const badge = (type === 'folder' || type === 'root') && kids.length > 0 ? '' + kids.length + '' : ''; + const content = '' + ICONS[type] + '' + escapeHtml(name) + '' + badge + ''; return { - content: '' + icon + '' + escapeHtml(name) + '', - payload: { type, path, name }, + content, + payload: { type, path, name, childCount: kids.length }, children: kids.map((k) => build(k.path, k.type, k.name)), }; }; @@ -42,6 +45,8 @@ const MARKMAP_OPTIONS = { initialExpandLevel: 2, maxWidth: 320, paddingX: 12, + spacingX: 90, + spacingY: 14, color: (node: { payload?: Record }) => { const t = node.payload?.type as NodeType | undefined; if (t === 'doc') return '#9aa0aa'; @@ -50,7 +55,7 @@ const MARKMAP_OPTIONS = { }, }; -function MindmapTree() { +function MindmapTree({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) { const svgRef = useRef(null); const mmRef = useRef(null); const tree = useStore(s => s.tree); @@ -61,8 +66,6 @@ function MindmapTree() { if (!svg) return; const mm = new Markmap(svg, MARKMAP_OPTIONS); mmRef.current = mm; - // 初始数据:StrictMode 双重渲染或视图切换时 Effect 2 不会重新运行, - // 这里直接读 store 当前 tree 设置初始数据,确保 mm 实例有内容 const curTree = useStore.getState().tree; if (curTree.length > 0) { mm.setData(toMarkmapTree(curTree) as never).catch(() => {}); @@ -71,7 +74,7 @@ function MindmapTree() { const target = e.target as Element | null; if (!target) return; if ((target as SVGElement).tagName === 'circle') return; - const nodeEl = (target as HTMLElement).closest?.('.mm-node') as Element | null; + const nodeEl = (target as HTMLElement).closest?.('.mm-node, .mm-pill') as Element | null; if (!nodeEl) return; let el: Element | null = nodeEl; while (el && !(el as { __data__?: unknown }).__data__) el = el.parentElement; @@ -80,6 +83,7 @@ function MindmapTree() { const p = d.payload; if (p?.type === 'doc' && typeof p.path === 'string' && p.path) { openDoc(p.path); + onSwitchToEditor?.(); } else if (p?.type === 'folder' || p?.type === 'root') { mm.toggleNode(d as never).catch(() => {}); } @@ -90,7 +94,7 @@ function MindmapTree() { try { mm.destroy(); } catch {} mmRef.current = null; }; - }, [openDoc]); + }, [openDoc, onSwitchToEditor]); useEffect(() => { const mm = mmRef.current; @@ -101,17 +105,21 @@ function MindmapTree() { return (
-
); @@ -129,7 +137,7 @@ export function GraphView({ onSwitchToEditor }: { onSwitchToEditor?: () => void - {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 a516476..2c7aa12 100644 --- a/apps/desktop/src/renderer/features/graph/LinkGraph.tsx +++ b/apps/desktop/src/renderer/features/graph/LinkGraph.tsx @@ -8,7 +8,7 @@ import { IPC, type GraphData, type GraphNode } from '@hunter/shared'; // M7 知识图谱:消费 IPC.graph(buildGraphData)渲染文档卡片 + frontmatter links 连线 type SimNode = GraphNode & SimulationNodeDatum; -const CARD_W = 170, CARD_H = 46; +const CARD_W = 180, CARD_H = 52; const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '...' : s); export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) { @@ -37,6 +37,10 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void const W = svgEl.clientWidth || 800, H = svgEl.clientHeight || 600; const svg = select(svgEl); svg.selectAll('*').remove(); + const defs = svg.append('defs'); + // 卡片投影(提升层次感) + const filter = defs.append('filter').attr('id', 'graph-card-shadow').attr('x', '-30%').attr('y', '-30%').attr('width', '160%').attr('height', '160%'); + filter.append('feDropShadow').attr('dx', 0).attr('dy', 2).attr('stdDeviation', 4).attr('flood-color', '#000').attr('flood-opacity', 0.28); const g = svg.append('g'); // FR-G4:scaleExtent [0.1, 2];zoom 绑定在背景 rect 上,不拦截卡片点击 @@ -48,43 +52,56 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void 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, + x: W / 2 + Math.cos((2 * Math.PI * i) / n) * 230, + y: H / 2 + Math.sin((2 * Math.PI * i) / n) * 230, })); 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', 'var(--control-border)').attr('stroke-opacity', 0.6) - .selectAll('line').data(links).join('line').attr('stroke-width', 1); + const linkSel = g.append('g').attr('stroke', 'var(--control-border)').attr('stroke-opacity', 0.55) + .selectAll('line').data(links).join('line').attr('stroke-width', 1.2); const sim = forceSimulation(nodes) - .force('link', (forceLink as any)(links).id((d: any) => d.id).distance(130).strength(0.2)) + .force('link', (forceLink as any)(links).id((d: any) => d.id).distance(140).strength(0.2)) .force('charge', forceManyBody().strength(-360)) .force('center', forceCenter(W / 2, H / 2)) - .force('collide', forceCollide(95)); + .force('collide', forceCollide(105)); 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'); - // 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('rect').attr('class', 'gcard').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 9) + .attr('fill', 'var(--bg-secondary)').attr('stroke', (d) => d.color || 'var(--control-border)').attr('stroke-width', 1.4) + .attr('filter', 'url(#graph-card-shadow)'); + // 左色条(圆角左侧) + nodeSel.append('rect').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2 + 4).attr('width', 4).attr('height', CARD_H - 8).attr('rx', 2).attr('fill', (d) => d.color || '#5db0ff'); + // 文件图标(lucide file 路径,不占 rect) + nodeSel.append('path') + .attr('d', 'M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z') + .attr('transform', 'translate(' + (-CARD_W / 2 + 18) + ',' + (-CARD_H / 2 + 8) + ') scale(1)') + .attr('fill', 'none').attr('stroke', (d) => d.color || '#5db0ff').attr('stroke-width', 1.8).attr('stroke-linejoin', 'round'); + nodeSel.append('path') + .attr('d', 'M13 2v6h6') + .attr('transform', 'translate(' + (-CARD_W / 2 + 18) + ',' + (-CARD_H / 2 + 8) + ')') + .attr('fill', 'none').attr('stroke', (d) => d.color || '#5db0ff').attr('stroke-width', 1.8).attr('stroke-linejoin', 'round'); // 标题 - 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.append('text').attr('x', -CARD_W / 2 + 34).attr('y', -4).attr('fill', 'var(--text-primary)').attr('font-size', 13).attr('font-weight', 600).attr('font-family', 'sans-serif') + .text((d) => truncate(d.title, 15)); + // 路径副文本 + nodeSel.append('text').attr('x', -CARD_W / 2 + 34).attr('y', 13).attr('fill', 'var(--text-tertiary)').attr('font-size', 9).attr('font-family', 'sans-serif') + .text((d) => truncate(d.basePath || d.id, 16)); // 星标 - 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.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 4).attr('fill', '#f5c518').attr('font-size', 14).text('★'); + + // 悬停高亮(CSS,不增加 rect) + svg.append('style').text('.gcard { transition: stroke-width .12s ease; } g[data-hover] .gcard { stroke-width: 2.2; }'); + nodeSel.attr('data-doc-path', (d: any) => d.id || '') + .on('mouseenter', function (_ev: any, d: any) { (this as Element).setAttribute('data-hover', '1'); }) + .on('mouseleave', function (_ev: any, d: any) { (this as Element).removeAttribute('data-hover'); }) // 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) => { @@ -124,7 +141,6 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void return (
- {/* FR-G4:画布控件 */}
@@ -136,4 +152,4 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void ); } -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 }; +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 }; \ No newline at end of file diff --git a/apps/desktop/tests/e2e/graph-mindmap-nav.spec.ts b/apps/desktop/tests/e2e/graph-mindmap-nav.spec.ts new file mode 100644 index 0000000..4653115 --- /dev/null +++ b/apps/desktop/tests/e2e/graph-mindmap-nav.spec.ts @@ -0,0 +1,53 @@ +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'); + await page.waitForTimeout(800); + const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args }); + return { app, page, invoke }; +} + +// 点击思维导图中的文档节点:应打开文档并切回编辑器界面 +test('思维导图点击文档节点 → 打开文档并切回编辑界面', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-mmnav-')); + 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: 'MindNav', isFolder: false }); + await a.invoke('doc:write', { path: 'MindNav.md', meta: { title: 'MindNav' }, body: '# MindNav' }); + await a.page.evaluate(() => (globalThis as any).__STORE__?.getState?.().loadTree()); + await a.page.waitForTimeout(800); + // 切到图谱视图(默认思维导图模式) + await a.page.getByTitle('切换 编辑器/图谱').click(); + await a.page.waitForTimeout(2500); + // 找到思维导图中的文档节点并点击(.mm-pill.mm-doc) + const clicked = await a.page.evaluate(() => { + const pills = document.querySelectorAll('.mm-pill.mm-doc'); + for (const p of pills) { + if (p.textContent && p.textContent.includes('MindNav')) { + (p as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + return true; + } + } + return false; + }); + expect(clicked).toBe(true); + await a.page.waitForTimeout(1500); + // 应打开文档并切回编辑器视图(ProseMirror 可见) + await expect(a.page.locator('.ProseMirror')).toBeVisible({ timeout: 5000 }); + const storeState = await a.page.evaluate(() => { + const s = (window as any).__STORE__; + return s ? { currentPath: s.getState().currentPath } : null; + }); + expect(storeState?.currentPath).toContain('MindNav'); + await a.app.close(); +});