feat(graph): 优化思维导图/知识图谱节点样式 + 文档点击跳转编辑器
- 思维导图: pill/chip 样式节点, 类型着色(root/folder/doc), 图标, 子节点计数徽标 - 知识图谱: 卡片圆角+投影+类型图标+星标, 悬停高亮 - 点击文档节点(思维导图/图谱)切换到编辑器界面 - 新增思维导图跳转 E2E 测试
This commit is contained in:
@@ -16,6 +16,9 @@ const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
|
||||
|
||||
type NodeType = 'root' | 'folder' | 'doc';
|
||||
|
||||
// 节点图标:根=库 / 文件夹=文件夹 / 文档=文件
|
||||
const ICONS: Record<NodeType, string> = { root: '🗂', folder: '📁', doc: '📄' };
|
||||
|
||||
function toMarkmapTree(nodes: TreeNode[]): PureNode {
|
||||
const byParent = new Map<string, TreeNode[]>();
|
||||
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 ? '<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 {
|
||||
content: '<span class="mm-node ' + cls + '"><span class="mm-ico">' + icon + '</span><span class="mm-name">' + escapeHtml(name) + '</span></span>',
|
||||
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<string, unknown> }) => {
|
||||
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<SVGSVGElement>(null);
|
||||
const mmRef = useRef<Markmap | null>(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 (
|
||||
<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%' }} />
|
||||
<style>{`
|
||||
.markmap-foreign { color: var(--text-primary); font-size: 13px; line-height: 1.2; }
|
||||
.mm-node { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.mm-ico { font-size: 13px; opacity: 0.85; }
|
||||
<style>{String.raw`
|
||||
.markmap-foreign { color: var(--text-primary); font-size: 13px; line-height: 1.4; }
|
||||
.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.9; line-height: 1; }
|
||||
.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-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 { cursor: pointer; }
|
||||
.mm-doc:hover .mm-name { color: var(--text-primary); text-decoration: underline; }
|
||||
.mm-folder, .mm-root { 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, .mm-folder, .mm-root { cursor: pointer; transition: background .12s ease, border-color .12s ease, transform .12s ease; }
|
||||
.mm-pill:hover { filter: brightness(1.15); }
|
||||
.mm-doc:hover .mm-name { color: var(--text-primary); }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
@@ -129,7 +137,7 @@ export function GraphView({ onSwitchToEditor }: { onSwitchToEditor?: () => void
|
||||
<button style={tabBtn(mode === 'mindmap')} onClick={() => setMode('mindmap')}>思维导图</button>
|
||||
<button style={tabBtn(mode === 'graph')} onClick={() => setMode('graph')}>知识图谱</button>
|
||||
</div>
|
||||
{mode === 'mindmap' ? <MindmapTree /> : <LinkGraph onSwitchToEditor={onSwitchToEditor} />}
|
||||
{mode === 'mindmap' ? <MindmapTree onSwitchToEditor={onSwitchToEditor} /> : <LinkGraph onSwitchToEditor={onSwitchToEditor} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<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('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 (
|
||||
<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%' }} />
|
||||
{/* FR-G4:画布控件 */}
|
||||
<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 / 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();
|
||||
});
|
||||
Reference in New Issue
Block a user