feat(M7): FR-G1/G4/G5 图谱卡片补全——卡片加文档图标+库内路径副文本(G1); scaleExtent改0.1-2+背景rect绑zoom不拦截点击+适配视图+缩放控件(G4); 点击卡片openDoc(d.id)+切回编辑视图(G5); graph E2E 3 项
This commit is contained in:
@@ -68,7 +68,7 @@ export function App() {
|
||||
</div>
|
||||
<Resizer side="left" onDrag={d => layout.setSidebarWidth(layout.sidebarWidth + d)} />
|
||||
</>)}
|
||||
{view === 'editor' ? <Editor /> : <GraphView />}
|
||||
{view === 'editor' ? <Editor /> : <GraphView onSwitchToEditor={() => setView('editor')} />}
|
||||
{view === 'editor' && layout.outlineVisible && (<>
|
||||
<Resizer side="right" onDrag={d => layout.setOutlineWidth(layout.outlineWidth + d)} />
|
||||
<div style={{ width: layout.outlineWidth, borderLeft: '1px solid #2a2a3a', display: 'flex', flexDirection: 'column', overflow: 'hidden', flexShrink: 0 }}>
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ flex: 1, position: 'relative', overflow: 'hidden', background: 'var(--bg-secondary)' }}>
|
||||
@@ -122,7 +122,7 @@ export function GraphView() {
|
||||
<button style={tabBtn(mode === 'mindmap')} onClick={() => setMode('mindmap')}>思维导图</button>
|
||||
<button style={tabBtn(mode === 'graph')} onClick={() => setMode('graph')}>知识图谱</button>
|
||||
</div>
|
||||
{mode === 'mindmap' ? <MindmapTree /> : <LinkGraph />}
|
||||
{mode === 'mindmap' ? <MindmapTree /> : <LinkGraph onSwitchToEditor={onSwitchToEditor} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<SVGSVGElement>(null);
|
||||
const { openDoc } = useStore();
|
||||
const [data, setData] = useState<GraphData | null>(null);
|
||||
const [err, setErr] = useState<string | null>(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<SVGSVGElement, unknown>().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<SVGSVGElement, unknown>().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<SimNode>(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<SVGGElement, SimNode>()
|
||||
.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 (
|
||||
<div style={{ flex: 1, background: 'var(--bg-secondary)', position: 'relative', overflow: 'hidden' }}>
|
||||
<svg 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>
|
||||
<button title="适配视图" onClick={fitView} style={ctrlBtn} data-testid="fit-view-btn">Fit</button>
|
||||
</div>
|
||||
{err && <div style={{ position: 'absolute', top: 8, left: 8, color: '#f88', fontSize: 12 }}>图谱加载失败:{err}</div>}
|
||||
{data && data.nodes.length === 0 && <div style={{ position: 'absolute', top: 8, left: 8, color: 'var(--text-tertiary)', fontSize: 12 }}>暂无文档(先建几篇笔记)</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user