From 39543a4456bf5dbccb5788b35dba4646dcee1208 Mon Sep 17 00:00:00 2001 From: hunter-dev Date: Mon, 24 Aug 2026 21:03:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9F=A5=E8=AF=86=E5=9B=BE=E8=B0=B1?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20XMind=20=E9=A3=8E=E6=A0=BC=E6=A8=AA?= =?UTF-8?q?=E5=90=91=E6=A0=91=EF=BC=88compactBox=20=E5=B8=83=E5=B1=80?= =?UTF-8?q?=EF=BC=8C=E6=A0=B9=E5=9C=A8=E5=B7=A6=E5=90=91=E5=8F=B3=E5=B1=95?= =?UTF-8?q?=E5=BC=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/renderer/features/graph/GraphView.tsx | 79 ++++++++++--------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/renderer/features/graph/GraphView.tsx b/apps/desktop/src/renderer/features/graph/GraphView.tsx index 8309c81..c72b1d0 100644 --- a/apps/desktop/src/renderer/features/graph/GraphView.tsx +++ b/apps/desktop/src/renderer/features/graph/GraphView.tsx @@ -1,29 +1,29 @@ import { useEffect, useRef } from 'react'; import { Graph } from '@antv/g6'; import { invoke } from '../../ipc-client'; -import { IPC, type GraphData, type GraphNode } from '@hunter/shared'; +import { IPC, type TreeNode } from '@hunter/shared'; import { useStore } from '../../store'; -function truncate(s: string, n: number) { return s.length > n ? s.slice(0, n) + '…' : s; } +interface MindNode { id: string; style?: any; data?: any; children?: MindNode[]; } -// 按顶层分组:每组一列,列内纵向均匀排开。坐标基于画布尺寸,保证铺满不挤。 -function layoutNodes(nodes: GraphNode[], w: number, h: number) { - const groups = new Map(); - nodes.forEach(n => { const a = groups.get(n.group) ?? []; a.push(n); groups.set(n.group, a); }); - const groupList = [...groups.entries()]; - const positions = new Map(); - if (groupList.length === 0 || w < 100 || h < 100) return positions; - const margin = 80; - const colWidth = (w - margin * 2) / Math.max(groupList.length, 1); - groupList.forEach(([, items], gi) => { - const colX = margin + colWidth * gi + colWidth / 2; - const usableH = h - margin * 2; - const rowGap = items.length > 1 ? usableH / (items.length + 1) : 0; - items.forEach((n, ni) => positions.set(n.id, { x: colX, y: margin + rowGap * (ni + 1) })); +// 从扁平 TreeNode[] 构建 XMind 风格的树(根=库,文件夹=分支,文档=叶子) +function buildMindTree(nodes: TreeNode[]): MindNode { + const byParent = new Map(); + nodes.forEach(n => { const a = byParent.get(n.parent) ?? []; a.push(n); byParent.set(n.parent, a); }); + const build = (parentPath: string): MindNode[] => (byParent.get(parentPath) ?? []).map(n => { + const isFolder = n.type === 'folder'; + return { + id: n.path, + style: { fill: isFolder ? '#2a4a6a' : '#3a3a4a', stroke: isFolder ? '#4a9eff' : '#5a5a6a' }, + data: { title: n.name, type: n.type, star: false }, + children: isFolder ? build(n.path) : undefined, + }; }); - return positions; + return { id: '__root', style: { fill: '#1a3a5a', stroke: '#4a9eff' }, data: { title: '库', type: 'root' }, children: build('') }; } +function truncate(s: string, n: number) { return s.length > n ? s.slice(0, n) + '…' : s; } + export function GraphView() { const containerRef = useRef(null); const { openDoc } = useStore(); @@ -37,37 +37,42 @@ export function GraphView() { container: el, width: 1200, height: 700, autoResize: true, + // 横向紧凑树(XMind 风格:根在左,向右展开) + layout: { + type: 'compactBox', + direction: 'LR', + getId: (d: any) => d.id, + getHeight: () => 28, + getWidth: () => 120, + getVGap: () => 12, + getHGap: () => 80, + } as any, node: { style: { - size: 32, - fill: (d: any) => (d.style && d.style.fill) || '#4a9eff', - stroke: '#6a8cff', lineWidth: 2, - labelText: (d: any) => truncate((d.data && d.data.title) || d.id, 12), - labelFill: '#eee', labelFontSize: 12, labelPlacement: 'bottom', labelOffsetY: 6, + size: (d: any) => [120, 28] 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: '#666', lineWidth: 1.5, endArrow: true } } as any, + edge: { style: { stroke: '#4a5a7a', lineWidth: 1.5 } } as any, behaviors: ['zoom-canvas', 'drag-canvas', 'drag-element'], }); } catch (e) { console.error(e); return; } - invoke(IPC.graph, undefined as never).then((data: GraphData) => { - const rect = el.getBoundingClientRect(); - const w = rect.width || 1200, h = rect.height || 700; - const pos = layoutNodes(data.nodes, w, h); - const nodes = data.nodes.map(n => ({ - id: n.id, - x: pos.get(n.id)?.x ?? w / 2, - y: pos.get(n.id)?.y ?? h / 2, - style: { fill: n.color, stroke: n.star ? '#ffd54a' : '#6a8cff' }, - data: { title: n.title, star: n.star }, - })); - const edges = data.edges.map(e => ({ source: e.source, target: e.target })); - g.setData({ nodes, edges } as any); + invoke(IPC.treeList, undefined as never).then((tree: TreeNode[]) => { + const mindRoot = buildMindTree(tree); + g.setData({ nodes: [mindRoot], 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) openDoc(e.target.id); }); + 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 {} }; }, []);