fix: 知识图谱与思维导图不显示内容
三个独立 bug 修复: 1. MindmapTree StrictMode 双重渲染: Effect 1 创建 mm 实例, Effect 2 设置数据, StrictMode 下 Effect 1 重建 mm 但 Effect 2 不重新运行 → 新 mm 无数据 修复: 在 Effect 1 中用 useStore.getState().tree 设置初始数据 2. LinkGraph onSwitchToEditor 不稳定: App 传内联箭头函数每次渲染新引用, 导致 D3 力导向图 Effect 2 依赖变化不断重置 → 图谱闪烁/不显示 修复: useCallback 稳定 switchToEditor 引用 3. frontmatter 块式 YAML 列表解析: parseList 只支持内联 [a, b] 格式, 用户手写 links:/tags: 多行 - item 格式无法解析 → 知识图谱无边 修复: parseYaml 支持 YAML 块式列表 (- item 多行) 新增 E2E 测试 graph-render.spec.ts 验证思维导图+知识图谱渲染内容 新增单元测试覆盖块式 YAML 列表解析
This commit is contained in:
@@ -25,7 +25,8 @@ export function joinFrontmatter(meta: DocMeta, body: string): string {
|
||||
function parseYaml(yaml: string): DocMeta {
|
||||
const meta: DocMeta = {};
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
const kv = line.match(/^([a-zA-Z]+):\s*(.*)$/);
|
||||
if (!kv) continue;
|
||||
@@ -34,8 +35,23 @@ function parseYaml(yaml: string): DocMeta {
|
||||
if (key === 'title') meta.title = unquote(v);
|
||||
else if (key === 'star') meta.star = v === 'true';
|
||||
else if (key === 'createdAt') { const n = Number(v); if (!isNaN(n)) meta.createdAt = n; }
|
||||
else if (key === 'tags') meta.tags = parseList(v);
|
||||
else if (key === 'links') meta.links = parseList(v);
|
||||
else if (key === 'tags' || key === 'links') {
|
||||
// 先试内联数组 [a, b];若值为空则尝试 YAML 块式列表(- item 多行)
|
||||
let list = parseList(v);
|
||||
if (list.length === 0 && v === '') {
|
||||
list = [];
|
||||
let j = i + 1;
|
||||
while (j < lines.length) {
|
||||
const item = lines[j].match(/^\s+-\s+(.*)$/);
|
||||
if (!item) break;
|
||||
list.push(unquote(item[1].trim()));
|
||||
j++;
|
||||
}
|
||||
if (list.length > 0) i = j - 1;
|
||||
}
|
||||
if (key === 'tags') meta.tags = list;
|
||||
else meta.links = list;
|
||||
}
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStore } from './store';
|
||||
import { invoke } from './ipc-client';
|
||||
import { IPC } from '@hunter/shared';
|
||||
@@ -41,6 +41,7 @@ export function App() {
|
||||
const appearance = useAppearance();
|
||||
const settings = useSettings();
|
||||
const layout = useLayout();
|
||||
const switchToEditor = useCallback(() => setView('editor'), []);
|
||||
|
||||
useEffect(() => { loadVault(); loadTree(); restore(); appearance.load(); useKeymap.getState().load(); useSettings.getState().loadGpu(); }, []);
|
||||
useEffect(() => { if (activeId && tabs.length > 0) { const t = tabs.find(x => x.id === activeId); if (t) openDoc(t.path).then(() => {
|
||||
@@ -93,7 +94,7 @@ export function App() {
|
||||
</div>
|
||||
<Resizer side="left" onDrag={d => layout.setSidebarWidth(layout.sidebarWidth + d)} />
|
||||
</>)}
|
||||
{view === 'editor' ? <Editor /> : <GraphView onSwitchToEditor={() => setView('editor')} />}
|
||||
{view === 'editor' ? <Editor /> : <GraphView onSwitchToEditor={switchToEditor} />}
|
||||
{view === 'editor' && layout.outlineVisible && (<>
|
||||
<Resizer side="right" onDrag={d => layout.setOutlineWidth(layout.outlineWidth + d)} />
|
||||
<div style={{ width: layout.outlineWidth, borderLeft: '1px solid var(--control-border)', display: 'flex', flexDirection: 'column', overflow: 'hidden', flexShrink: 0 }}>
|
||||
|
||||
@@ -61,6 +61,12 @@ 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(() => {});
|
||||
}
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const target = e.target as Element | null;
|
||||
if (!target) return;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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');
|
||||
|
||||
test('思维导图+知识图谱渲染验证', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-graph-fix-'));
|
||||
const userData = nodePath.join(tmp, 'userdata');
|
||||
const vault = nodePath.join(tmp, 'vault');
|
||||
await fs.mkdir(userData, { recursive: true });
|
||||
await fs.mkdir(vault, { recursive: true });
|
||||
await fs.writeFile(nodePath.join(vault, 'doc1.md'), '---\ntitle: 文档一\nlinks:\n - doc2.md\n---\n# 文档一\n链接到 doc2');
|
||||
await fs.writeFile(nodePath.join(vault, 'doc2.md'), '---\ntitle: 文档二\n---\n# 文档二\n内容');
|
||||
|
||||
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
|
||||
await invoke('vault:switch', { root: vault });
|
||||
await page.evaluate(() => (window as any).__STORE__.getState().loadTree());
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify IPC.graph returns correct edges
|
||||
const gd: any = await invoke('graph', null);
|
||||
console.log('IPC.graph nodes:', gd.nodes.length, 'edges:', JSON.stringify(gd.edges));
|
||||
|
||||
// Switch to graph view (default mode is mindmap)
|
||||
await page.getByTitle('切换 编辑器/图谱').click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const mindmapSvg = await page.evaluate(() => {
|
||||
const svg = document.querySelector('.markmap-dark svg');
|
||||
if (!svg) return { found: false, total: 0 };
|
||||
const g = svg.querySelectorAll('g').length;
|
||||
const fo = svg.querySelectorAll('foreignObject').length;
|
||||
const paths = svg.querySelectorAll('path').length;
|
||||
return { found: true, total: g + fo + paths };
|
||||
});
|
||||
console.log('Mindmap SVG content:', JSON.stringify(mindmapSvg));
|
||||
expect(mindmapSvg.found).toBe(true);
|
||||
expect(mindmapSvg.total).toBeGreaterThan(5);
|
||||
|
||||
// Switch to knowledge graph mode
|
||||
await page.getByRole('button', { name: '知识图谱' }).click();
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const graphContent = await page.evaluate(() => {
|
||||
return {
|
||||
rects: document.querySelectorAll('svg rect').length,
|
||||
texts: document.querySelectorAll('svg text').length,
|
||||
lines: document.querySelectorAll('svg line').length,
|
||||
};
|
||||
});
|
||||
console.log('Knowledge graph SVG content:', JSON.stringify(graphContent));
|
||||
expect(graphContent.rects).toBeGreaterThanOrEqual(5);
|
||||
expect(graphContent.texts).toBeGreaterThanOrEqual(3);
|
||||
expect(graphContent.lines).toBeGreaterThanOrEqual(1); // doc1 -> doc2 edge
|
||||
|
||||
await app.close();
|
||||
});
|
||||
Reference in New Issue
Block a user