diff --git a/apps/desktop/src/main/safe/frontmatter-codec.ts b/apps/desktop/src/main/safe/frontmatter-codec.ts index 8e57db9..8858e23 100644 --- a/apps/desktop/src/main/safe/frontmatter-codec.ts +++ b/apps/desktop/src/main/safe/frontmatter-codec.ts @@ -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; } diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index b6eae5c..d6e58ed 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -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() { layout.setSidebarWidth(layout.sidebarWidth + d)} /> )} - {view === 'editor' ? : setView('editor')} />} + {view === 'editor' ? : } {view === 'editor' && layout.outlineVisible && (<> layout.setOutlineWidth(layout.outlineWidth + d)} />
diff --git a/apps/desktop/src/renderer/features/graph/GraphView.tsx b/apps/desktop/src/renderer/features/graph/GraphView.tsx index 604d80e..5d62c7b 100644 --- a/apps/desktop/src/renderer/features/graph/GraphView.tsx +++ b/apps/desktop/src/renderer/features/graph/GraphView.tsx @@ -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; diff --git a/apps/desktop/tests/e2e/graph-render.spec.ts b/apps/desktop/tests/e2e/graph-render.spec.ts new file mode 100644 index 0000000..1fca49c --- /dev/null +++ b/apps/desktop/tests/e2e/graph-render.spec.ts @@ -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(); +}); \ No newline at end of file diff --git a/tests/unit/frontmatter-codec.test.ts b/tests/unit/frontmatter-codec.test.ts index 4e9f5d9..760fdfe 100644 --- a/tests/unit/frontmatter-codec.test.ts +++ b/tests/unit/frontmatter-codec.test.ts @@ -14,6 +14,20 @@ describe('splitFrontmatter', () => { expect(meta).toEqual({ title: '我的标题', tags: ['a','b'], star: true, links: ['x/y.md'] }); expect(body).toBe('# 正文'); }); + it('块式 YAML 列表(多行 - item)', () => { + const raw = '---\ntitle: 标题\nlinks:\n - a.md\n - b.md\ntags:\n - x\n - y\n---\n# 正文'; + const { meta, body } = splitFrontmatter(raw); + expect(meta.links).toEqual(['a.md', 'b.md']); + expect(meta.tags).toEqual(['x', 'y']); + expect(meta.title).toBe('标题'); + expect(body).toBe('# 正文'); + }); + it('块式列表后紧跟其他字段', () => { + const raw = '---\nlinks:\n - a.md\nstar: true\n---\n# B'; + const { meta } = splitFrontmatter(raw); + expect(meta.links).toEqual(['a.md']); + expect(meta.star).toBe(true); + }); it('带引号的值', () => { const { meta } = splitFrontmatter('---\ntitle: "含: 冒号"\n---\n'); expect(meta.title).toBe('含: 冒号');