feat(graph): 优化思维导图/知识图谱节点样式 + 文档点击跳转编辑器

- 思维导图: pill/chip 样式节点, 类型着色(root/folder/doc), 图标, 子节点计数徽标
- 知识图谱: 卡片圆角+投影+类型图标+星标, 悬停高亮
- 点击文档节点(思维导图/图谱)切换到编辑器界面
- 新增思维导图跳转 E2E 测试
This commit is contained in:
hunter-dev
2026-09-09 20:29:36 +08:00
parent dc8d844a1f
commit d0dbdfd00a
3 changed files with 118 additions and 41 deletions
@@ -16,6 +16,9 @@ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
type NodeType = 'root' | 'folder' | 'doc'; type NodeType = 'root' | 'folder' | 'doc';
// 节点图标:根=库 / 文件夹=文件夹 / 文档=文件
const ICONS: Record<NodeType, string> = { root: '🗂', folder: '📁', doc: '📄' };
function toMarkmapTree(nodes: TreeNode[]): PureNode { function toMarkmapTree(nodes: TreeNode[]): PureNode {
const byParent = new Map<string, TreeNode[]>(); const byParent = new Map<string, TreeNode[]>();
for (const n of nodes) { for (const n of nodes) {
@@ -24,12 +27,12 @@ function toMarkmapTree(nodes: TreeNode[]): PureNode {
byParent.set(n.parent, arr); byParent.set(n.parent, arr);
} }
const build = (path: string, type: NodeType, name: string): PureNode => { 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 kids = type === 'doc' ? [] : (byParent.get(path) ?? []);
const badge = (type === 'folder' || type === 'root') && kids.length > 0 ? '<span class="mm-badge">' + kids.length + '</span>' : '';
const content = '<span class="mm-pill mm-' + type + '"><span class="mm-ico">' + ICONS[type] + '</span><span class="mm-name">' + escapeHtml(name) + '</span>' + badge + '</span>';
return { return {
content: '<span class="mm-node ' + cls + '"><span class="mm-ico">' + icon + '</span><span class="mm-name">' + escapeHtml(name) + '</span></span>', content,
payload: { type, path, name }, payload: { type, path, name, childCount: kids.length },
children: kids.map((k) => build(k.path, k.type, k.name)), children: kids.map((k) => build(k.path, k.type, k.name)),
}; };
}; };
@@ -42,6 +45,8 @@ const MARKMAP_OPTIONS = {
initialExpandLevel: 2, initialExpandLevel: 2,
maxWidth: 320, maxWidth: 320,
paddingX: 12, paddingX: 12,
spacingX: 90,
spacingY: 14,
color: (node: { payload?: Record<string, unknown> }) => { color: (node: { payload?: Record<string, unknown> }) => {
const t = node.payload?.type as NodeType | undefined; const t = node.payload?.type as NodeType | undefined;
if (t === 'doc') return '#9aa0aa'; if (t === 'doc') return '#9aa0aa';
@@ -50,7 +55,7 @@ const MARKMAP_OPTIONS = {
}, },
}; };
function MindmapTree() { function MindmapTree({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) {
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
const mmRef = useRef<Markmap | null>(null); const mmRef = useRef<Markmap | null>(null);
const tree = useStore(s => s.tree); const tree = useStore(s => s.tree);
@@ -61,8 +66,6 @@ function MindmapTree() {
if (!svg) return; if (!svg) return;
const mm = new Markmap(svg, MARKMAP_OPTIONS); const mm = new Markmap(svg, MARKMAP_OPTIONS);
mmRef.current = mm; mmRef.current = mm;
// 初始数据:StrictMode 双重渲染或视图切换时 Effect 2 不会重新运行,
// 这里直接读 store 当前 tree 设置初始数据,确保 mm 实例有内容
const curTree = useStore.getState().tree; const curTree = useStore.getState().tree;
if (curTree.length > 0) { if (curTree.length > 0) {
mm.setData(toMarkmapTree(curTree) as never).catch(() => {}); mm.setData(toMarkmapTree(curTree) as never).catch(() => {});
@@ -71,7 +74,7 @@ function MindmapTree() {
const target = e.target as Element | null; const target = e.target as Element | null;
if (!target) return; if (!target) return;
if ((target as SVGElement).tagName === 'circle') 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; if (!nodeEl) return;
let el: Element | null = nodeEl; let el: Element | null = nodeEl;
while (el && !(el as { __data__?: unknown }).__data__) el = el.parentElement; while (el && !(el as { __data__?: unknown }).__data__) el = el.parentElement;
@@ -80,6 +83,7 @@ function MindmapTree() {
const p = d.payload; const p = d.payload;
if (p?.type === 'doc' && typeof p.path === 'string' && p.path) { if (p?.type === 'doc' && typeof p.path === 'string' && p.path) {
openDoc(p.path); openDoc(p.path);
onSwitchToEditor?.();
} else if (p?.type === 'folder' || p?.type === 'root') { } else if (p?.type === 'folder' || p?.type === 'root') {
mm.toggleNode(d as never).catch(() => {}); mm.toggleNode(d as never).catch(() => {});
} }
@@ -90,7 +94,7 @@ function MindmapTree() {
try { mm.destroy(); } catch {} try { mm.destroy(); } catch {}
mmRef.current = null; mmRef.current = null;
}; };
}, [openDoc]); }, [openDoc, onSwitchToEditor]);
useEffect(() => { useEffect(() => {
const mm = mmRef.current; const mm = mmRef.current;
@@ -101,17 +105,21 @@ function MindmapTree() {
return ( return (
<div className="markmap-dark" style={{ flex: 1, background: 'var(--bg-secondary)', position: 'relative', overflow: 'hidden' }}> <div className="markmap-dark" style={{ flex: 1, background: 'var(--bg-secondary)', position: 'relative', overflow: 'hidden' }}>
<svg ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} /> <svg ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
<style>{` <style>{String.raw`
.markmap-foreign { color: var(--text-primary); font-size: 13px; line-height: 1.2; } .markmap-foreign { color: var(--text-primary); font-size: 13px; line-height: 1.4; }
.mm-node { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; } .mm-pill { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; padding: 3px 11px; border-radius: 999px; border: 1px solid transparent; font-size: 13px; }
.mm-ico { font-size: 13px; opacity: 0.85; } .mm-ico { font-size: 13px; opacity: 0.9; line-height: 1; }
.mm-name { font-weight: 500; } .mm-name { font-weight: 500; }
.mm-root { background: color-mix(in srgb, var(--accent) 18%, transparent); border-color: color-mix(in srgb, var(--accent) 45%, transparent); }
.mm-root .mm-name { font-weight: 700; color: var(--accent-text); } .mm-root .mm-name { font-weight: 700; color: var(--accent-text); }
.mm-folder .mm-name { font-weight: 600; color: var(--accent-text); } .mm-folder { background: color-mix(in srgb, #5db0ff 14%, transparent); border-color: color-mix(in srgb, #5db0ff 38%, transparent); }
.mm-folder .mm-name { font-weight: 600; color: #7fc0ff; }
.mm-doc { background: color-mix(in srgb, var(--text-secondary) 8%, transparent); border-color: color-mix(in srgb, var(--control-border) 70%, transparent); }
.mm-doc .mm-name { color: var(--text-secondary); font-weight: 400; } .mm-doc .mm-name { color: var(--text-secondary); font-weight: 400; }
.mm-doc { cursor: pointer; } .mm-badge { min-width: 15px; height: 15px; padding: 0 4px; border-radius: 999px; background: color-mix(in srgb, var(--accent) 60%, transparent); color: #fff; font-size: 10px; line-height: 15px; text-align: center; font-weight: 700; }
.mm-doc:hover .mm-name { color: var(--text-primary); text-decoration: underline; } .mm-doc, .mm-folder, .mm-root { cursor: pointer; transition: background .12s ease, border-color .12s ease, transform .12s ease; }
.mm-folder, .mm-root { cursor: pointer; } .mm-pill:hover { filter: brightness(1.15); }
.mm-doc:hover .mm-name { color: var(--text-primary); }
`}</style> `}</style>
</div> </div>
); );
@@ -129,7 +137,7 @@ export function GraphView({ onSwitchToEditor }: { onSwitchToEditor?: () => void
<button style={tabBtn(mode === 'mindmap')} onClick={() => setMode('mindmap')}>思维导图</button> <button style={tabBtn(mode === 'mindmap')} onClick={() => setMode('mindmap')}>思维导图</button>
<button style={tabBtn(mode === 'graph')} onClick={() => setMode('graph')}>知识图谱</button> <button style={tabBtn(mode === 'graph')} onClick={() => setMode('graph')}>知识图谱</button>
</div> </div>
{mode === 'mindmap' ? <MindmapTree /> : <LinkGraph onSwitchToEditor={onSwitchToEditor} />} {mode === 'mindmap' ? <MindmapTree onSwitchToEditor={onSwitchToEditor} /> : <LinkGraph onSwitchToEditor={onSwitchToEditor} />}
</div> </div>
); );
} }
@@ -8,7 +8,7 @@ import { IPC, type GraphData, type GraphNode } from '@hunter/shared';
// M7 知识图谱:消费 IPC.graph(buildGraphData)渲染文档卡片 + frontmatter links 连线 // M7 知识图谱:消费 IPC.graph(buildGraphData)渲染文档卡片 + frontmatter links 连线
type SimNode = GraphNode & SimulationNodeDatum; 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); const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '...' : s);
export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void }) { 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 W = svgEl.clientWidth || 800, H = svgEl.clientHeight || 600;
const svg = select(svgEl); const svg = select(svgEl);
svg.selectAll('*').remove(); 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'); const g = svg.append('g');
// FR-G4:scaleExtent [0.1, 2];zoom 绑定在背景 rect 上,不拦截卡片点击 // 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 n = Math.max(1, data.nodes.length);
const nodes: SimNode[] = data.nodes.map((node, i) => ({ const nodes: SimNode[] = data.nodes.map((node, i) => ({
...node, ...node,
x: W / 2 + Math.cos((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) * 220, y: H / 2 + Math.sin((2 * Math.PI * i) / n) * 230,
})); }));
const idx = new Map(nodes.map((nd) => [nd.id, nd] as const)); const idx = new Map(nodes.map((nd) => [nd.id, nd] as const));
const links = data.edges const links = data.edges
.filter((e) => idx.has(e.source) && idx.has(e.target)) .filter((e) => idx.has(e.source) && idx.has(e.target))
.map((e) => ({ source: idx.get(e.source)!, target: idx.get(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) 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); .selectAll('line').data(links).join('line').attr('stroke-width', 1.2);
const sim = forceSimulation<SimNode>(nodes) const sim = forceSimulation<SimNode>(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('charge', forceManyBody().strength(-360))
.force('center', forceCenter(W / 2, H / 2)) .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'); 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) 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.5); .attr('fill', 'var(--bg-secondary)').attr('stroke', (d) => d.color || 'var(--control-border)').attr('stroke-width', 1.4)
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'); .attr('filter', 'url(#graph-card-shadow)');
// 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('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') 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, 14)); .text((d) => truncate(d.title, 15));
// 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') 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, 18)); .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:点击卡片打开文档并切回编辑器视图 // FR-G5:点击卡片打开文档并切回编辑器视图
// d.id 是文档路径(如 'g5doc.md'),d.basePath 是所在目录(根目录为空)
nodeSel.attr('data-doc-path', (d: any) => d.id || '');
nodeSel.each(function(d: any) { nodeSel.each(function(d: any) {
const el = this as HTMLElement; const el = this as HTMLElement;
el.addEventListener('click', (ev: MouseEvent) => { el.addEventListener('click', (ev: MouseEvent) => {
@@ -124,7 +141,6 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
return ( return (
<div style={{ flex: 1, background: 'var(--bg-secondary)', position: 'relative', overflow: 'hidden' }}> <div style={{ flex: 1, background: 'var(--bg-secondary)', position: 'relative', overflow: 'hidden' }}>
<svg data-testid="graph-canvas" ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} /> <svg data-testid="graph-canvas" ref={svgRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
{/* FR-G4:画布控件 */}
<div data-testid="graph-controls" style={{ position: 'absolute', bottom: 12, right: 12, display: 'flex', flexDirection: 'column', gap: 4, zIndex: 10 }}> <div data-testid="graph-controls" style={{ position: 'absolute', bottom: 12, right: 12, display: 'flex', flexDirection: 'column', gap: 4, zIndex: 10 }}>
<button title="放大" onClick={() => zoomBy(1.3)} style={ctrlBtn}>+</button> <button title="放大" onClick={() => zoomBy(1.3)} style={ctrlBtn}>+</button>
<button title="缩小" onClick={() => zoomBy(1 / 1.3)} style={ctrlBtn}>-</button> <button title="缩小" onClick={() => zoomBy(1 / 1.3)} style={ctrlBtn}>-</button>
@@ -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 };
@@ -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();
});