Skip to content

Commit 318470e

Browse files
committed
feat(notes): 阶段四 Markdown 往返(tiptap-markdown 导出/导入,导图降级大纲,wikiLink 转文本)
1 parent db7c1f8 commit 318470e

7 files changed

Lines changed: 407 additions & 4 deletions

File tree

‎client/package-lock.json‎

Lines changed: 159 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎client/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"tailwind-merge": "^3.6.0",
7979
"tailwindcss-animate": "^1.0.7",
8080
"three": "^0.185.1",
81+
"tiptap-markdown": "^0.9.0",
8182
"uuid": "^14.0.1",
8283
"zustand": "^5.0.14"
8384
},

‎client/src/features/notes/components/NoteEditHeader.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* 通过 onSummarize 提供。
88
*/
99
import type { Ref } from 'react';
10-
import { ArrowLeft, Save, HelpCircle } from 'lucide-react';
10+
import { ArrowLeft, Save, HelpCircle, Download } from 'lucide-react';
1111
import { cn } from '@/lib/utils';
1212
import { AIButton } from '@/components/ui/AIButton';
1313

@@ -57,6 +57,8 @@ export interface NoteEditHeaderProps {
5757
onManualSave: () => void;
5858
onOpenRescue: () => void;
5959
onSummarize: () => void;
60+
/** 阶段四:导出当前笔记为 Markdown */
61+
onExportMarkdown: () => void;
6062
}
6163

6264
const SECONDARY_BTN = cn(
@@ -68,7 +70,7 @@ const SECONDARY_BTN = cn(
6870

6971
export function NoteEditHeader({
7072
title, titleRef, saveStatus, aiLoading,
71-
onBack, onTitleBlur, onTitleKeyDown, onManualSave, onOpenRescue, onSummarize,
73+
onBack, onTitleBlur, onTitleKeyDown, onManualSave, onOpenRescue, onSummarize, onExportMarkdown,
7274
}: NoteEditHeaderProps) {
7375
return (
7476
<div className="relative z-10 flex items-center gap-kb-sm px-kb-md py-3 border-b border-border/50 flex-shrink-0">
@@ -110,6 +112,11 @@ export function NoteEditHeader({
110112
保存
111113
</button>
112114

115+
<button onClick={onExportMarkdown} className={SECONDARY_BTN} title="导出为 Markdown">
116+
<Download className="w-icon-sm h-icon-sm" strokeWidth={1.5} />
117+
导出 Md
118+
</button>
119+
113120
<button onClick={onOpenRescue} className={SECONDARY_BTN} title="卡壳了 (Ctrl+Shift+H)">
114121
<HelpCircle className="w-icon-sm h-icon-sm" strokeWidth={1.5} />
115122
卡壳了
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* noteMarkdown 单测:Markdown 往返一致性
3+
* Unit tests for Markdown round-trip
4+
*/
5+
import { describe, it, expect } from 'vitest';
6+
import { noteToMarkdown, markdownToNoteContent } from './noteMarkdown';
7+
8+
/** 构造 TipTap doc JSON 字符串 */
9+
function tiptapDoc(...content: unknown[]): string {
10+
return JSON.stringify({ type: 'doc', content });
11+
}
12+
13+
describe('noteMarkdown', () => {
14+
it('标题与段落转 Markdown', () => {
15+
const md = noteToMarkdown(tiptapDoc(
16+
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '标题' }] },
17+
{ type: 'paragraph', content: [{ type: 'text', text: '正文' }] },
18+
));
19+
expect(md).toContain('# 标题');
20+
expect(md).toContain('正文');
21+
});
22+
23+
it('粗体/斜体/行内代码', () => {
24+
const md = noteToMarkdown(tiptapDoc({
25+
type: 'paragraph',
26+
content: [
27+
{ type: 'text', text: '粗', marks: [{ type: 'bold' }] },
28+
{ type: 'text', text: '斜', marks: [{ type: 'italic' }] },
29+
{ type: 'text', text: '码', marks: [{ type: 'code' }] },
30+
],
31+
}));
32+
expect(md).toContain('**粗**');
33+
expect(md).toContain('*斜*');
34+
expect(md).toContain('`码`');
35+
});
36+
37+
it('无序列表', () => {
38+
const md = noteToMarkdown(tiptapDoc({
39+
type: 'bulletList',
40+
content: [
41+
{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '项一' }] }] },
42+
],
43+
}));
44+
expect(md).toMatch(/[-*] 项一/);
45+
});
46+
47+
it('wikiLink 降级为 [[label]] 文本(方括号被 markdown 转义)', () => {
48+
const md = noteToMarkdown(tiptapDoc({
49+
type: 'paragraph',
50+
content: [{ type: 'wikiLink', attrs: { id: 'n1', label: '目标笔记' } }],
51+
}));
52+
// 标签保留;方括号可能被 markdown 转义为 \[\[
53+
expect(md).toContain('目标笔记');
54+
expect(md.replace(/\\/g, '')).toContain('[[目标笔记]]');
55+
});
56+
57+
it('Markdown 转 TipTap JSON(标题+粗体)', () => {
58+
const json = markdownToNoteContent('# 标题\n\n**粗体** 正文');
59+
const doc = JSON.parse(json) as { type: string };
60+
expect(doc.type).toBe('doc');
61+
const str = JSON.stringify(doc);
62+
expect(str).toContain('标题');
63+
expect(str).toContain('heading');
64+
expect(str).toContain('bold');
65+
});
66+
67+
it('导图笔记降级为大纲纯文本', () => {
68+
const mindmap = JSON.stringify({
69+
type: 'mindmap',
70+
version: 1,
71+
root: { id: 'r', text: '中心', children: [{ id: 'a', text: '分支', children: [] }] },
72+
});
73+
const md = noteToMarkdown(mindmap);
74+
expect(md).toContain('中心');
75+
expect(md).toContain('分支');
76+
});
77+
78+
it('损坏内容返回空字符串', () => {
79+
expect(noteToMarkdown('not json')).toBe('');
80+
});
81+
});

0 commit comments

Comments
 (0)