feat(M5+): 导入导出功能、性能优化、单元测试

导出:Markdown / Word(.docx) / PDF(打印) / 纯文本,编辑器标题栏下拉菜单
导入:.md / .txt / .docx(mammoth) / .pdf(pdfjs-dist),侧边栏底部按钮
Store:createNote 支持 init 参数,filteredNotes 增加缓存层
测试:vitest 23 个单元测试(utils + filterNotes 逻辑)
构建:vite manualChunks 分包优化

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MikiVL 2026-05-03 01:16:11 +08:00
parent aa1430d4bf
commit 9c534a920d
15 changed files with 2174 additions and 74 deletions

5
.gitignore vendored
View File

@ -22,3 +22,8 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Secrets & runtime config
.env
models.json

168
README.md
View File

@ -1,73 +1,121 @@
# React + TypeScript + Vite # StudyNote
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 基于 Vite + React + TypeScript 构建的现代化 AI 笔记应用,提供富文本编辑、灵活的文件夹组织,以及由 Claude API 驱动的 AI 写作辅助能力。
Currently, two official plugins are available: ## 技术栈
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) | 层次 | 选型 |
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) |------|------|
| 构建工具 | Vite 5 |
| 前端框架 | React 19 + TypeScript |
| 编辑器 | TipTap v3 |
| 样式 | Tailwind CSS v4 |
| 状态管理 | Zustand v5 |
| 本地存储 | IndexedDBDexie.js |
| AI 接入 | Anthropic SDKclaude-sonnet-4-6 |
| 后端代理 | HonoNode.js端口 3001 |
| 动画 | Framer Motion |
| 拖拽 | @dnd-kit |
## React Compiler ## 快速开始
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). ```bash
# 1. 克隆仓库
git clone https://git.muchen.fan/MikiVL/studynote.git
cd studynote
## Expanding the ESLint configuration # 2. 安装依赖
npm install
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: # 3. 配置 API Key
cp .env.example .env
# 编辑 .env填入你的 ANTHROPIC_API_KEY 和 ANTHROPIC_BASE_URL
```js # 4. 启动开发服务器(同时启动前端 + AI 代理)
export default defineConfig([ npm run dev
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: 浏览器访问 `http://localhost:5173`
```js ## 功能一览
// eslint.config.js
import reactX from 'eslint-plugin-react-x' ### 富文本编辑
import reactDom from 'eslint-plugin-react-dom' - 标题H1H4、段落、引用块、代码块含语法高亮
- 粗体、斜体、下划线、删除线、行内代码、高亮
- 有序/无序列表、任务列表Checkbox
- 表格、图片插入
- `/` 斜杠命令菜单快速插入内容块
- 浮动工具栏(选中文字后出现)
- 自动保存(防抖 1s
### 笔记组织
- 多级文件夹嵌套
- 笔记标签(多标签、按标签过滤)
- 收藏标记
- 全文搜索(标题 + 正文 + 标签)
- 排序:按修改时间、创建时间、标题
- 拖拽移动笔记到文件夹
- 笔记/文件夹重命名、删除
### AI 功能
- **AI 续写**:点击 ⚡ 按钮,基于当前内容流式续写
- **AI 润色**:选中文字 → 浮动工具栏 → 润色,预览后替换
- **AI 摘要**:选中段落或全文生成摘要
- **翻译成英文**:选中文字 → 浮动工具栏 → 翻译
- **AI 问答**:可拖拽/缩放浮窗,针对当前笔记自由提问
- **模型管理**:支持添加多个 OpenAI 兼容模型,一键切换
### 界面体验
- 亮色 / 暗色主题切换
- 专注模式(隐藏侧边栏,全屏写作)
- 弹窗缩放动画Framer Motion
## 键盘快捷键
| 快捷键 | 功能 |
|--------|------|
| `Cmd/Ctrl + N` | 新建笔记 |
| `Cmd/Ctrl + \` | 切换专注模式 |
| `Cmd/Ctrl + Shift + J` | 打开 AI 助手 |
| `Cmd/Ctrl + B` | 粗体 |
| `Cmd/Ctrl + I` | 斜体 |
| `Cmd/Ctrl + Z` | 撤销 |
| `Cmd/Ctrl + Shift + Z` | 重做 |
| `/` | 命令菜单 |
| `Esc` | 退出专注模式 |
## 项目结构
export default defineConfig([ ```
globalIgnores(['dist']), studynote/
{ ├── src/
files: ['**/*.{ts,tsx}'], │ ├── components/
extends: [ │ │ ├── editor/ # TipTap 编辑器、浮动工具栏、欢迎页
// Other configs... │ │ ├── sidebar/ # 左侧导航栏
// Enable lint rules for React │ │ └── ai/ # AI 面板、模型管理弹窗
reactX.configs['recommended-typescript'], │ ├── stores/ # Zustand store
// Enable lint rules for React DOM │ ├── db/ # Dexie.js IndexedDB 封装
reactDom.configs.recommended, │ ├── lib/ # 工具函数、AI 流式请求
], │ └── test/ # Vitest 单元测试
languageOptions: { ├── server/ # Hono AI 代理服务
parserOptions: { ├── .env.example # 环境变量模板
project: ['./tsconfig.node.json', './tsconfig.app.json'], └── vite.config.ts
tsconfigRootDir: import.meta.dirname, ```
},
// other options... ## 环境变量
},
}, 复制 `.env.example``.env` 并填写:
])
```
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_BASE_URL=https://api.anthropic.com
```
> `.env` 和运行时生成的 `models.json` 已加入 `.gitignore`,不会被提交。
## 构建
```bash
npm run build # 生产构建,输出到 dist/
npm run preview # 本地预览生产构建
``` ```

963
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,9 @@
"dev:server": "tsx --watch server/index.ts", "dev:server": "tsx --watch server/index.ts",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.92.0", "@anthropic-ai/sdk": "^0.92.0",
@ -48,11 +50,14 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"docx": "^9.6.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"hono": "^4.12.16", "hono": "^4.12.16",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"mammoth": "^1.12.0",
"pdfjs-dist": "^5.7.284",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
@ -76,6 +81,7 @@
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
"vite": "^8.0.10" "vite": "^8.0.10",
"vitest": "^4.1.5"
} }
} }

View File

@ -30,6 +30,7 @@ import { useAppStore } from '../../stores/appStore'
import { countWords } from '../../lib/utils' import { countWords } from '../../lib/utils'
import { streamAI } from '../../lib/ai' import { streamAI } from '../../lib/ai'
import { WelcomeView } from './WelcomeView' import { WelcomeView } from './WelcomeView'
import { ExportMenu } from './ExportMenu'
const lowlight = createLowlight(common) const lowlight = createLowlight(common)
@ -385,6 +386,9 @@ export function Editor() {
style={{ color: activeNote.starred ? '#f59e0b' : 'var(--text-faint)' }} style={{ color: activeNote.starred ? '#f59e0b' : 'var(--text-faint)' }}
/> />
</button> </button>
<div className="shrink-0 mt-1.5">
<ExportMenu title={title} content={activeNote.content} />
</div>
<button <button
onClick={toggleFocusMode} onClick={toggleFocusMode}
title={focusMode ? '退出专注模式 (Esc)' : '专注模式'} title={focusMode ? '退出专注模式 (Esc)' : '专注模式'}

View File

@ -0,0 +1,122 @@
import { useState, useRef, useEffect } from 'react'
import { Download, FileText, FileType, Printer, AlignLeft } from 'lucide-react'
import { exportMarkdown, exportTxt, exportPDF, exportDocx } from '../../lib/export'
interface ExportMenuProps {
title: string
content: string
}
export function ExportMenu({ title, content }: ExportMenuProps) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
window.addEventListener('mousedown', handler)
return () => window.removeEventListener('mousedown', handler)
}, [open])
const handleDocx = async () => {
setLoading(true)
setOpen(false)
try {
await exportDocx(title, content)
} finally {
setLoading(false)
}
}
return (
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
<button
title="导出"
onClick={() => setOpen(v => !v)}
disabled={loading}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '4px 8px',
borderRadius: 6,
border: '1px solid var(--border)',
background: 'var(--bg-muted)',
color: 'var(--text-muted)',
cursor: loading ? 'wait' : 'pointer',
fontSize: 13,
}}
>
<Download size={14} />
{loading ? '导出中…' : '导出'}
</button>
{open && (
<div
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
right: 0,
zIndex: 200,
background: 'var(--bg-muted)',
border: '1px solid var(--border)',
borderRadius: 8,
boxShadow: '0 4px 16px rgba(0,0,0,0.25)',
opacity: 1,
minWidth: 160,
overflow: 'hidden',
}}
>
{[
{
label: 'Markdown',
icon: <FileText size={14} />,
onClick: () => { exportMarkdown(title, content); setOpen(false) },
},
{
label: 'Word (.docx)',
icon: <FileType size={14} />,
onClick: handleDocx,
},
{
label: 'PDF (打印)',
icon: <Printer size={14} />,
onClick: () => { exportPDF(); setOpen(false) },
},
{
label: '纯文本',
icon: <AlignLeft size={14} />,
onClick: () => { exportTxt(title, content); setOpen(false) },
},
].map(item => (
<button
key={item.label}
onClick={item.onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
padding: '8px 14px',
border: 'none',
background: 'transparent',
color: 'var(--text)',
cursor: 'pointer',
fontSize: 13,
textAlign: 'left',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--hover)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{item.icon}
{item.label}
</button>
))}
</div>
)}
</div>
)
}

View File

@ -4,9 +4,10 @@ import {
Search, Plus, Star, FileText, Folder, FolderOpen, Search, Plus, Star, FileText, Folder, FolderOpen,
ChevronRight, ChevronDown, ChevronRight, ChevronDown,
Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen, Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen,
ArrowUpDown, Check, X, Bot, ArrowUpDown, Check, X, Bot, Upload,
} from 'lucide-react' } from 'lucide-react'
import { ModelSettingsModal } from '../ai/ModelSettingsModal' import { ModelSettingsModal } from '../ai/ModelSettingsModal'
import { importMarkdown, importTxt, importDocx, importPDF } from '../../lib/import'
import { import {
DndContext, DragOverlay, useDraggable, useDroppable, DndContext, DragOverlay, useDraggable, useDroppable,
useSensor, useSensors, MouseSensor, TouchSensor, useSensor, useSensors, MouseSensor, TouchSensor,
@ -35,6 +36,7 @@ export function Sidebar() {
const [noteEditValue, setNoteEditValue] = useState('') const [noteEditValue, setNoteEditValue] = useState('')
const [sortMenuOpen, setSortMenuOpen] = useState(false) const [sortMenuOpen, setSortMenuOpen] = useState(false)
const [modelModalOpen, setModelModalOpen] = useState(false) const [modelModalOpen, setModelModalOpen] = useState(false)
const [localSearch, setLocalSearch] = useState(searchQuery)
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
type: 'note' | 'folder' type: 'note' | 'folder'
id: string id: string
@ -53,6 +55,11 @@ export function Sidebar() {
const newFolderRef = useRef<HTMLInputElement | null>(null) const newFolderRef = useRef<HTMLInputElement | null>(null)
const displayed = filteredNotes() const displayed = filteredNotes()
useEffect(() => {
const t = setTimeout(() => setSearch(localSearch), 300)
return () => clearTimeout(t)
}, [localSearch, setSearch])
useEffect(() => { useEffect(() => {
if (editingFolderId && editInputRef.current) editInputRef.current.focus() if (editingFolderId && editInputRef.current) editInputRef.current.focus()
}, [editingFolderId]) }, [editingFolderId])
@ -104,6 +111,31 @@ export function Sidebar() {
await createNote(folderId) await createNote(folderId)
} }
const importFileRef = useRef<HTMLInputElement>(null)
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
const folderId = typeof activeFolderId === 'string' && activeFolderId !== 'all' && activeFolderId !== 'starred'
? activeFolderId
: null
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
let result: { title: string; content: string }
if (ext === 'md' || ext === 'markdown') {
result = importMarkdown(await file.text())
} else if (ext === 'txt') {
result = importTxt(await file.text())
} else if (ext === 'docx') {
result = await importDocx(await file.arrayBuffer())
} else if (ext === 'pdf') {
result = await importPDF(await file.arrayBuffer())
} else {
return
}
await createNote(folderId, result)
}
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = (event: DragStartEvent) => {
setDraggingNoteId(event.active.id as string) setDraggingNoteId(event.active.id as string)
} }
@ -182,8 +214,8 @@ export function Sidebar() {
> >
<Search size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} /> <Search size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<input <input
value={searchQuery} value={localSearch}
onChange={e => setSearch(e.target.value)} onChange={e => setLocalSearch(e.target.value)}
placeholder="搜索笔记…" placeholder="搜索笔记…"
className="bg-transparent outline-none text-sm w-full" className="bg-transparent outline-none text-sm w-full"
style={{ color: 'var(--text)' }} style={{ color: 'var(--text)' }}
@ -356,6 +388,24 @@ export function Sidebar() {
<Bot size={13} /> <Bot size={13} />
AI AI
</button> </button>
<button
onClick={() => importFileRef.current?.click()}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-xs transition-colors"
style={{ color: 'var(--text-faint)' }}
title="导入文件 (.md .txt .docx .pdf)"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-muted)'; e.currentTarget.style.color = 'var(--text)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Upload size={13} />
</button>
<input
ref={importFileRef}
type="file"
accept=".md,.markdown,.txt,.docx,.pdf"
style={{ display: 'none' }}
onChange={handleImportFile}
/>
</div> </div>
</aside> </aside>

View File

@ -285,3 +285,28 @@
border-radius: 1px; border-radius: 1px;
animation: ai-blink 0.8s ease infinite; animation: ai-blink 0.8s ease infinite;
} }
/* ── Print / PDF export ── */
@media print {
aside, [data-ai-panel], .floating-toolbar, .editor-toolbar,
.toolbar-btn, [class*="sidebar"] {
display: none !important;
}
.ProseMirror {
padding: 0 !important;
min-height: unset !important;
}
body {
overflow: visible !important;
background: white !important;
color: black !important;
}
#root {
display: block !important;
height: auto !important;
}
.flex-1.flex.flex-col.min-w-0.h-full {
display: block !important;
height: auto !important;
}
}

236
src/lib/export.ts Normal file
View File

@ -0,0 +1,236 @@
import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType, UnderlineType } from 'docx'
import { extractTextFromJSON } from './utils'
// ── Shared ────────────────────────────────────────────────────────────────────
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
setTimeout(() => URL.revokeObjectURL(url), 10000)
}
// ── TipTap JSON types ─────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TipTapNode = any
// ── Markdown export ───────────────────────────────────────────────────────────
function inlineToMd(node: TipTapNode): string {
if (node.type === 'hardBreak') return ' \n'
if (node.type === 'image') return `![${node.attrs?.alt ?? ''}](${node.attrs?.src ?? ''})`
if (node.type !== 'text') return ''
let text: string = node.text ?? ''
const marks: string[] = (node.marks ?? []).map((m: TipTapNode) => m.type)
const linkMark = (node.marks ?? []).find((m: TipTapNode) => m.type === 'link')
if (marks.includes('code')) return `\`${text}\``
if (linkMark) text = `[${text}](${linkMark.attrs?.href ?? ''})`
if (marks.includes('bold')) text = `**${text}**`
if (marks.includes('italic')) text = `*${text}*`
if (marks.includes('strike')) text = `~~${text}~~`
if (marks.includes('underline')) text = `<u>${text}</u>`
if (marks.includes('highlight')) text = `==${text}==`
return text
}
function inlinesToMd(nodes: TipTapNode[]): string {
return (nodes ?? []).map(inlineToMd).join('')
}
function nodeToMd(node: TipTapNode, listDepth = 0, listIndex = { n: 1 }): string {
const indent = ' '.repeat(listDepth)
switch (node.type) {
case 'heading': {
const level = node.attrs?.level ?? 1
return `${'#'.repeat(level)} ${inlinesToMd(node.content)}\n\n`
}
case 'paragraph': {
const text = inlinesToMd(node.content ?? [])
return text ? `${text}\n\n` : '\n'
}
case 'blockquote':
return (node.content ?? []).map((n: TipTapNode) => nodeToMd(n).replace(/^/gm, '> ')).join('') + '\n'
case 'codeBlock': {
const lang = node.attrs?.language ?? ''
const code = (node.content ?? []).map((n: TipTapNode) => n.text ?? '').join('')
return `\`\`\`${lang}\n${code}\n\`\`\`\n\n`
}
case 'bulletList':
return (node.content ?? []).map((n: TipTapNode) => nodeToMd(n, listDepth, listIndex)).join('') + '\n'
case 'orderedList': {
const idx = { n: 1 }
return (node.content ?? []).map((n: TipTapNode) => nodeToMd(n, listDepth, idx)).join('') + '\n'
}
case 'listItem': {
const children = node.content ?? []
const first = children[0]
const firstText = first ? inlinesToMd(first.content ?? []) : ''
const rest = children.slice(1).map((n: TipTapNode) => nodeToMd(n, listDepth + 1, { n: 1 })).join('')
return `${indent}- ${firstText}\n${rest}`
}
case 'taskList':
return (node.content ?? []).map((n: TipTapNode) => nodeToMd(n, listDepth, listIndex)).join('') + '\n'
case 'taskItem': {
const checked = node.attrs?.checked ? 'x' : ' '
const children = node.content ?? []
const first = children[0]
const firstText = first ? inlinesToMd(first.content ?? []) : ''
const rest = children.slice(1).map((n: TipTapNode) => nodeToMd(n, listDepth + 1, { n: 1 })).join('')
return `${indent}- [${checked}] ${firstText}\n${rest}`
}
case 'table': {
const rows: TipTapNode[] = node.content ?? []
if (!rows.length) return ''
const cells = (row: TipTapNode) =>
(row.content ?? []).map((cell: TipTapNode) => inlinesToMd((cell.content?.[0]?.content) ?? [])).join(' | ')
const header = `| ${cells(rows[0])} |`
const sep = `| ${(rows[0].content ?? []).map(() => '---').join(' | ')} |`
const body = rows.slice(1).map((r: TipTapNode) => `| ${cells(r)} |`).join('\n')
return `${header}\n${sep}\n${body}\n\n`
}
case 'horizontalRule':
return '---\n\n'
case 'image':
return `![${node.attrs?.alt ?? ''}](${node.attrs?.src ?? ''})\n\n`
default:
return ''
}
}
export function exportMarkdown(title: string, content: string): void {
let md = `# ${title}\n\n`
try {
const doc = JSON.parse(content)
md += (doc.content ?? []).map((n: TipTapNode) => nodeToMd(n)).join('')
} catch {
md += content
}
downloadBlob(new Blob([md], { type: 'text/markdown;charset=utf-8' }), `${title}.md`)
}
// ── Plain text export ─────────────────────────────────────────────────────────
export function exportTxt(title: string, content: string): void {
const text = `${title}\n\n${extractTextFromJSON(content)}`
downloadBlob(new Blob([text], { type: 'text/plain;charset=utf-8' }), `${title}.txt`)
}
// ── PDF export ────────────────────────────────────────────────────────────────
export function exportPDF(): void {
window.print()
}
// ── DOCX export ───────────────────────────────────────────────────────────────
function inlinesToDocxRuns(nodes: TipTapNode[]): TextRun[] {
return (nodes ?? []).flatMap((node: TipTapNode): TextRun[] => {
if (node.type === 'hardBreak') return [new TextRun({ break: 1 })]
if (node.type !== 'text') return []
const marks: string[] = (node.marks ?? []).map((m: TipTapNode) => m.type)
return [new TextRun({
text: node.text ?? '',
bold: marks.includes('bold'),
italics: marks.includes('italic'),
strike: marks.includes('strike'),
underline: marks.includes('underline') ? { type: UnderlineType.SINGLE } : undefined,
font: marks.includes('code') ? 'Courier New' : undefined,
})]
})
}
function nodesToDocxParagraphs(nodes: TipTapNode[]): Paragraph[] {
return (nodes ?? []).flatMap((node: TipTapNode): Paragraph[] => {
switch (node.type) {
case 'heading':
return [new Paragraph({
heading: ([
HeadingLevel.HEADING_1,
HeadingLevel.HEADING_2,
HeadingLevel.HEADING_3,
HeadingLevel.HEADING_4,
])[(node.attrs?.level ?? 1) - 1] ?? HeadingLevel.HEADING_1,
children: inlinesToDocxRuns(node.content ?? []),
})]
case 'paragraph':
return [new Paragraph({ children: inlinesToDocxRuns(node.content ?? []) })]
case 'blockquote':
return nodesToDocxParagraphs(node.content ?? []).map(p => {
// indent blockquote
return new Paragraph({
indent: { left: 720 },
children: (p as Paragraph & { options: { children: TextRun[] } }).options?.children ?? [],
})
})
case 'codeBlock': {
const code = (node.content ?? []).map((n: TipTapNode) => n.text ?? '').join('')
return code.split('\n').map(line => new Paragraph({
children: [new TextRun({ text: line, font: 'Courier New' })],
}))
}
case 'bulletList':
return (node.content ?? []).flatMap((item: TipTapNode) => {
const first = item.content?.[0]
return [new Paragraph({
bullet: { level: 0 },
children: inlinesToDocxRuns(first?.content ?? []),
})]
})
case 'orderedList':
return (node.content ?? []).flatMap((item: TipTapNode, i: number) => {
const first = item.content?.[0]
return [new Paragraph({
numbering: { reference: 'default-numbering', level: 0 },
children: [new TextRun(`${i + 1}. `), ...inlinesToDocxRuns(first?.content ?? [])],
})]
})
case 'taskList':
return (node.content ?? []).flatMap((item: TipTapNode) => {
const checked = item.attrs?.checked ? '☑' : '☐'
const first = item.content?.[0]
return [new Paragraph({
children: [new TextRun(`${checked} `), ...inlinesToDocxRuns(first?.content ?? [])],
})]
})
case 'horizontalRule':
return [new Paragraph({
border: { bottom: { color: 'auto', space: 1, style: 'single', size: 6 } },
children: [],
alignment: AlignmentType.CENTER,
})]
case 'image':
// Images require async fetch; skip in sync path
return [new Paragraph({ children: [new TextRun(`[图片: ${node.attrs?.src ?? ''}]`)] })]
default:
return []
}
})
}
export async function exportDocx(title: string, content: string): Promise<void> {
let bodyParagraphs: Paragraph[] = []
try {
const doc = JSON.parse(content)
bodyParagraphs = nodesToDocxParagraphs(doc.content ?? [])
} catch {
bodyParagraphs = [new Paragraph({ children: [new TextRun(content)] })]
}
const docx = new Document({
sections: [{
properties: {},
children: [
new Paragraph({ heading: HeadingLevel.TITLE, children: [new TextRun(title)] }),
...bodyParagraphs,
],
}],
})
const blob = await Packer.toBlob(docx)
downloadBlob(blob, `${title}.docx`)
}

366
src/lib/import.ts Normal file
View File

@ -0,0 +1,366 @@
import mammoth from 'mammoth'
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist'
GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/5.7.284/pdf.worker.min.mjs'
// ── Types ─────────────────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TipTapNode = any
export interface ImportResult {
title: string
content: string // TipTap JSON string
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeDoc(nodes: TipTapNode[]): string {
return JSON.stringify({ type: 'doc', content: nodes.length ? nodes : [{ type: 'paragraph' }] })
}
function textNode(text: string, marks: TipTapNode[] = []): TipTapNode {
const n: TipTapNode = { type: 'text', text }
if (marks.length) n.marks = marks
return n
}
function paragraph(children: TipTapNode[]): TipTapNode {
return { type: 'paragraph', content: children.length ? children : undefined }
}
// ── Markdown inline parser ────────────────────────────────────────────────────
function parseInline(raw: string): TipTapNode[] {
const nodes: TipTapNode[] = []
// Regex order matters: code first, then links, then bold/italic combos
const re =
/(`[^`]+`)|(\*\*\*(.+?)\*\*\*)|(\*\*(.+?)\*\*)|(\*(.+?)\*)|(__(.+?)__)|(_(.+?)_)|(~~(.+?)~~)|(==(.+?)==)|(\[([^\]]+)\]\(([^)]+)\))|(<u>(.+?)<\/u>)/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null) {
if (m.index > last) nodes.push(textNode(raw.slice(last, m.index)))
if (m[1]) {
// `code`
nodes.push(textNode(m[1].slice(1, -1), [{ type: 'code' }]))
} else if (m[2]) {
// ***bold+italic***
nodes.push(textNode(m[3], [{ type: 'bold' }, { type: 'italic' }]))
} else if (m[4]) {
// **bold**
nodes.push(textNode(m[5], [{ type: 'bold' }]))
} else if (m[6]) {
// *italic*
nodes.push(textNode(m[7], [{ type: 'italic' }]))
} else if (m[8]) {
// __bold__
nodes.push(textNode(m[9], [{ type: 'bold' }]))
} else if (m[10]) {
// _italic_
nodes.push(textNode(m[11], [{ type: 'italic' }]))
} else if (m[12]) {
// ~~strike~~
nodes.push(textNode(m[13], [{ type: 'strike' }]))
} else if (m[14]) {
// ==highlight==
nodes.push(textNode(m[15], [{ type: 'highlight' }]))
} else if (m[16]) {
// [text](url)
nodes.push(textNode(m[17], [{ type: 'link', attrs: { href: m[18] } }]))
} else if (m[19]) {
// <u>underline</u>
nodes.push(textNode(m[20], [{ type: 'underline' }]))
}
last = m.index + m[0].length
}
if (last < raw.length) nodes.push(textNode(raw.slice(last)))
return nodes
}
// ── Markdown block parser ─────────────────────────────────────────────────────
export function importMarkdown(text: string): ImportResult {
const lines = text.split('\n')
const nodes: TipTapNode[] = []
let title = ''
let i = 0
while (i < lines.length) {
const line = lines[i]
// Heading
const headingMatch = line.match(/^(#{1,4})\s+(.*)/)
if (headingMatch) {
const level = headingMatch[1].length
const content = parseInline(headingMatch[2])
if (!title && level === 1) title = headingMatch[2]
nodes.push({ type: 'heading', attrs: { level }, content })
i++
continue
}
// Fenced code block
const fenceMatch = line.match(/^```(\w*)/)
if (fenceMatch) {
const lang = fenceMatch[1]
const codeLines: string[] = []
i++
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i])
i++
}
i++ // skip closing ```
nodes.push({
type: 'codeBlock',
attrs: { language: lang || null },
content: [{ type: 'text', text: codeLines.join('\n') }],
})
continue
}
// Blockquote
if (line.startsWith('> ')) {
const quoteLines: string[] = []
while (i < lines.length && lines[i].startsWith('> ')) {
quoteLines.push(lines[i].slice(2))
i++
}
nodes.push({
type: 'blockquote',
content: [paragraph(parseInline(quoteLines.join(' ')))],
})
continue
}
// Horizontal rule
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
nodes.push({ type: 'horizontalRule' })
i++
continue
}
// Task list item
const taskMatch = line.match(/^(\s*)- \[([x ])\] (.*)/)
if (taskMatch) {
const items: TipTapNode[] = []
let j = i
while (j < lines.length) {
const tm = lines[j].match(/^(\s*)- \[([x ])\] (.*)/)
if (!tm) break
items.push({
type: 'taskItem',
attrs: { checked: tm[2] === 'x' },
content: [paragraph(parseInline(tm[3]))],
})
j++
}
nodes.push({ type: 'taskList', content: items })
i = j
continue
}
// Bullet list
const bulletMatch = line.match(/^(\s*)[-*+] (.*)/)
if (bulletMatch) {
const items: TipTapNode[] = []
let j = i
while (j < lines.length) {
const bm = lines[j].match(/^(\s*)[-*+] (.*)/)
if (!bm) break
items.push({
type: 'listItem',
content: [paragraph(parseInline(bm[2]))],
})
j++
}
nodes.push({ type: 'bulletList', content: items })
i = j
continue
}
// Ordered list
const orderedMatch = line.match(/^(\s*)\d+\. (.*)/)
if (orderedMatch) {
const items: TipTapNode[] = []
let j = i
while (j < lines.length) {
const om = lines[j].match(/^(\s*)\d+\. (.*)/)
if (!om) break
items.push({
type: 'listItem',
content: [paragraph(parseInline(om[2]))],
})
j++
}
nodes.push({ type: 'orderedList', content: items })
i = j
continue
}
// Image
const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)/)
if (imgMatch) {
nodes.push({ type: 'image', attrs: { src: imgMatch[2], alt: imgMatch[1] } })
i++
continue
}
// Empty line
if (line.trim() === '') {
i++
continue
}
// Paragraph
nodes.push(paragraph(parseInline(line)))
i++
}
if (!title) title = '导入的笔记'
return { title, content: makeDoc(nodes) }
}
// ── Plain text import ─────────────────────────────────────────────────────────
export function importTxt(text: string): ImportResult {
const lines = text.split('\n')
const title = lines[0]?.trim() || '导入的笔记'
const nodes: TipTapNode[] = lines.map(line =>
line.trim() === '' ? { type: 'paragraph' } : paragraph(parseInline(line)),
)
return { title, content: makeDoc(nodes) }
}
// ── HTML → TipTap JSON (used by DOCX import) ──────────────────────────────────
function htmlToTipTap(html: string): TipTapNode[] {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
return Array.from(doc.body.childNodes).flatMap(n => domNodeToTipTap(n as Element))
}
function getInlineMarks(el: Element): TipTapNode[] {
const marks: TipTapNode[] = []
const tag = el.tagName?.toLowerCase()
if (tag === 'strong' || tag === 'b') marks.push({ type: 'bold' })
if (tag === 'em' || tag === 'i') marks.push({ type: 'italic' })
if (tag === 'u') marks.push({ type: 'underline' })
if (tag === 's' || tag === 'del') marks.push({ type: 'strike' })
if (tag === 'code') marks.push({ type: 'code' })
if (tag === 'a') marks.push({ type: 'link', attrs: { href: (el as HTMLAnchorElement).href } })
return marks
}
function domInlineToRuns(node: Node, inheritedMarks: TipTapNode[] = []): TipTapNode[] {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ''
if (!text) return []
return [textNode(text, inheritedMarks)]
}
if (node.nodeType !== Node.ELEMENT_NODE) return []
const el = node as Element
const marks = [...inheritedMarks, ...getInlineMarks(el)]
return Array.from(el.childNodes).flatMap(c => domInlineToRuns(c, marks))
}
function domNodeToTipTap(el: Element): TipTapNode[] {
if (el.nodeType === Node.TEXT_NODE) {
const text = el.textContent?.trim() ?? ''
return text ? [paragraph([textNode(text)])] : []
}
if (el.nodeType !== Node.ELEMENT_NODE) return []
const tag = el.tagName?.toLowerCase()
const hMatch = tag?.match(/^h([1-4])$/)
if (hMatch) {
return [{ type: 'heading', attrs: { level: parseInt(hMatch[1]) }, content: domInlineToRuns(el) }]
}
if (tag === 'p') {
const runs = domInlineToRuns(el)
return [paragraph(runs)]
}
if (tag === 'ul') {
const items = Array.from(el.querySelectorAll(':scope > li')).map(li => ({
type: 'listItem',
content: [paragraph(domInlineToRuns(li))],
}))
return items.length ? [{ type: 'bulletList', content: items }] : []
}
if (tag === 'ol') {
const items = Array.from(el.querySelectorAll(':scope > li')).map(li => ({
type: 'listItem',
content: [paragraph(domInlineToRuns(li))],
}))
return items.length ? [{ type: 'orderedList', content: items }] : []
}
if (tag === 'blockquote') {
return [{ type: 'blockquote', content: Array.from(el.childNodes).flatMap(c => domNodeToTipTap(c as Element)) }]
}
if (tag === 'pre' || tag === 'code') {
return [{ type: 'codeBlock', attrs: { language: null }, content: [{ type: 'text', text: el.textContent ?? '' }] }]
}
if (tag === 'hr') return [{ type: 'horizontalRule' }]
if (tag === 'img') {
return [{ type: 'image', attrs: { src: (el as HTMLImageElement).src, alt: (el as HTMLImageElement).alt } }]
}
if (tag === 'br') return []
// Fallback: recurse into children
return Array.from(el.childNodes).flatMap(c => domNodeToTipTap(c as Element))
}
// ── DOCX import ───────────────────────────────────────────────────────────────
export async function importDocx(arrayBuffer: ArrayBuffer): Promise<ImportResult> {
const result = await mammoth.convertToHtml(
{ arrayBuffer },
{
convertImage: mammoth.images.imgElement(async img => {
const data = await img.read('base64')
return { src: `data:${img.contentType};base64,${data}` }
}),
},
)
const nodes = htmlToTipTap(result.value)
const title = nodes.find(n => n.type === 'heading')?.content?.[0]?.text ?? '导入的文档'
return { title, content: makeDoc(nodes) }
}
// ── PDF import ────────────────────────────────────────────────────────────────
export async function importPDF(arrayBuffer: ArrayBuffer): Promise<ImportResult> {
const pdf = await getDocument({ data: arrayBuffer }).promise
const nodes: TipTapNode[] = []
let title = ''
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum)
const textContent = await page.getTextContent()
// Group items into lines by y-coordinate (rounded to nearest 2px)
const lineMap = new Map<number, string[]>()
for (const item of textContent.items) {
if (!('str' in item)) continue
const y = Math.round((item as { transform: number[] }).transform[5] / 2) * 2
if (!lineMap.has(y)) lineMap.set(y, [])
lineMap.get(y)!.push((item as { str: string }).str)
}
// Sort lines top-to-bottom (descending y in PDF coords)
const sortedYs = Array.from(lineMap.keys()).sort((a, b) => b - a)
for (const y of sortedYs) {
const lineText = lineMap.get(y)!.join('').trim()
if (!lineText) continue
if (!title && pageNum === 1) title = lineText
nodes.push(paragraph([textNode(lineText)]))
}
// Page break between pages (except last)
if (pageNum < pdf.numPages) {
nodes.push({ type: 'horizontalRule' })
}
}
if (!title) title = '导入的PDF'
return { title, content: makeDoc(nodes) }
}

View File

@ -14,10 +14,12 @@ interface AppState {
activeTag: string | null activeTag: string | null
sortBy: 'updatedAt' | 'createdAt' | 'title' sortBy: 'updatedAt' | 'createdAt' | 'title'
sortOrder: 'asc' | 'desc' sortOrder: 'asc' | 'desc'
_notesVersion: number
_filteredCache: { key: string; result: Note[] } | null
// actions // actions
loadAll: () => Promise<void> loadAll: () => Promise<void>
createNote: (folderId?: string | null) => Promise<string> createNote: (folderId?: string | null, init?: { title?: string; content?: string }) => Promise<string>
updateNote: (id: string, patch: Partial<Note>, opts?: { silent?: boolean }) => Promise<void> updateNote: (id: string, patch: Partial<Note>, opts?: { silent?: boolean }) => Promise<void>
deleteNote: (id: string) => Promise<void> deleteNote: (id: string) => Promise<void>
toggleStar: (id: string) => Promise<void> toggleStar: (id: string) => Promise<void>
@ -51,13 +53,15 @@ export const useAppStore = create<AppState>((set, get) => ({
activeTag: null, activeTag: null,
sortBy: 'updatedAt', sortBy: 'updatedAt',
sortOrder: 'desc', sortOrder: 'desc',
_notesVersion: 0,
_filteredCache: null,
loadAll: async () => { loadAll: async () => {
const [notes, folders] = await Promise.all([ const [notes, folders] = await Promise.all([
db.notes.orderBy('updatedAt').reverse().toArray(), db.notes.orderBy('updatedAt').reverse().toArray(),
db.folders.orderBy('order').toArray(), db.folders.orderBy('order').toArray(),
]) ])
set({ notes, folders }) set({ notes, folders, _notesVersion: get()._notesVersion + 1, _filteredCache: null })
// Keep welcome screen as default; only auto-select if already on a real note // Keep welcome screen as default; only auto-select if already on a real note
const cur = get().activeNoteId const cur = get().activeNoteId
if (cur && cur !== '__welcome__' && !notes.find(n => n.id === cur)) { if (cur && cur !== '__welcome__' && !notes.find(n => n.id === cur)) {
@ -65,13 +69,13 @@ export const useAppStore = create<AppState>((set, get) => ({
} }
}, },
createNote: async (folderId = null) => { createNote: async (folderId = null, init?: { title?: string; content?: string }) => {
const id = generateId() const id = generateId()
const now = Date.now() const now = Date.now()
const note: Note = { const note: Note = {
id, id,
title: '无标题笔记', title: init?.title ?? '无标题笔记',
content: JSON.stringify({ type: 'doc', content: [{ type: 'paragraph' }] }), content: init?.content ?? JSON.stringify({ type: 'doc', content: [{ type: 'paragraph' }] }),
folderId: folderId ?? get().activeFolderId as string | null, folderId: folderId ?? get().activeFolderId as string | null,
tags: [], tags: [],
starred: false, starred: false,
@ -80,7 +84,7 @@ export const useAppStore = create<AppState>((set, get) => ({
wordCount: 0, wordCount: 0,
} }
await db.notes.add(note) await db.notes.add(note)
set(s => ({ notes: [note, ...s.notes], activeNoteId: id })) set(s => ({ notes: [note, ...s.notes], activeNoteId: id, _notesVersion: s._notesVersion + 1, _filteredCache: null }))
return id return id
}, },
@ -93,6 +97,8 @@ export const useAppStore = create<AppState>((set, get) => ({
notes: s.notes.map(n => notes: s.notes.map(n =>
n.id === id ? { ...n, ...dbPatch } : n n.id === id ? { ...n, ...dbPatch } : n
).sort((a, b) => b.updatedAt - a.updatedAt), ).sort((a, b) => b.updatedAt - a.updatedAt),
_notesVersion: s._notesVersion + 1,
_filteredCache: null,
})) }))
}, },
@ -101,7 +107,7 @@ export const useAppStore = create<AppState>((set, get) => ({
set(s => { set(s => {
const notes = s.notes.filter(n => n.id !== id) const notes = s.notes.filter(n => n.id !== id)
const activeNoteId = s.activeNoteId === id ? '__welcome__' : s.activeNoteId const activeNoteId = s.activeNoteId === id ? '__welcome__' : s.activeNoteId
return { notes, activeNoteId } return { notes, activeNoteId, _notesVersion: s._notesVersion + 1, _filteredCache: null }
}) })
}, },
@ -152,7 +158,10 @@ export const useAppStore = create<AppState>((set, get) => ({
setSortOrder: (order) => set({ sortOrder: order }), setSortOrder: (order) => set({ sortOrder: order }),
filteredNotes: () => { filteredNotes: () => {
const { notes, activeFolderId, searchQuery, activeTag, sortBy, sortOrder } = get() const { notes, activeFolderId, searchQuery, activeTag, sortBy, sortOrder, _notesVersion, _filteredCache } = get()
const cacheKey = `${_notesVersion}|${activeFolderId}|${searchQuery}|${activeTag}|${sortBy}|${sortOrder}`
if (_filteredCache?.key === cacheKey) return _filteredCache.result
let result = notes let result = notes
if (activeFolderId === 'starred') { if (activeFolderId === 'starred') {
@ -184,6 +193,7 @@ export const useAppStore = create<AppState>((set, get) => ({
return sortOrder === 'asc' ? cmp : -cmp return sortOrder === 'asc' ? cmp : -cmp
}) })
set({ _filteredCache: { key: cacheKey, result } })
return result return result
}, },
})) }))

159
src/test/store.test.ts Normal file
View File

@ -0,0 +1,159 @@
import { describe, it, expect } from 'vitest'
import { extractTextFromJSON } from '../lib/utils'
// Mirror the filteredNotes logic as a pure function for testing
type Note = {
id: string
title: string
content: string
folderId: string | null
tags: string[]
starred: boolean
updatedAt: number
createdAt: number
wordCount: number
}
function filterNotes(
notes: Note[],
opts: {
activeFolderId: string | null | 'all' | 'starred'
searchQuery: string
activeTag: string | null
sortBy: 'updatedAt' | 'createdAt' | 'title'
sortOrder: 'asc' | 'desc'
},
): Note[] {
const { activeFolderId, searchQuery, activeTag, sortBy, sortOrder } = opts
let result = notes
if (activeFolderId === 'starred') {
result = result.filter(n => n.starred)
} else if (activeFolderId !== 'all' && activeFolderId !== null) {
result = result.filter(n => n.folderId === activeFolderId)
}
if (activeTag) {
result = result.filter(n => n.tags.includes(activeTag))
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase()
result = result.filter(n =>
n.title.toLowerCase().includes(q) ||
n.tags.some(t => t.toLowerCase().includes(q)) ||
extractTextFromJSON(n.content).toLowerCase().includes(q),
)
}
result = [...result].sort((a, b) => {
let cmp = 0
if (sortBy === 'title') {
cmp = a.title.localeCompare(b.title, 'zh-CN')
} else {
cmp = a[sortBy] - b[sortBy]
}
return sortOrder === 'asc' ? cmp : -cmp
})
return result
}
const makeNote = (overrides: Partial<Note> & { id: string }): Note => ({
title: '无标题',
content: JSON.stringify({ type: 'doc', content: [] }),
folderId: null,
tags: [],
starred: false,
updatedAt: 1000,
createdAt: 1000,
wordCount: 0,
...overrides,
})
const NOTES: Note[] = [
makeNote({ id: '1', title: '工作计划', folderId: 'folder-a', tags: ['工作'], updatedAt: 3000, createdAt: 1000 }),
makeNote({ id: '2', title: '读书笔记', folderId: 'folder-b', tags: ['学习', '读书'], starred: true, updatedAt: 2000, createdAt: 2000 }),
makeNote({ id: '3', title: 'TypeScript 入门', folderId: null, tags: ['技术', '学习'], updatedAt: 1000, createdAt: 3000 }),
makeNote({ id: '4', title: '收藏的想法', starred: true, updatedAt: 4000, createdAt: 500 }),
]
describe('filterNotes — folder filter', () => {
it('returns all notes when activeFolderId is "all"', () => {
const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' })
expect(result).toHaveLength(4)
})
it('filters by specific folderId', () => {
const result = filterNotes(NOTES, { activeFolderId: 'folder-a', searchQuery: '', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' })
expect(result).toHaveLength(1)
expect(result[0].id).toBe('1')
})
it('returns only starred notes', () => {
const result = filterNotes(NOTES, { activeFolderId: 'starred', searchQuery: '', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' })
expect(result.every(n => n.starred)).toBe(true)
expect(result).toHaveLength(2)
})
})
describe('filterNotes — tag filter', () => {
it('filters by activeTag', () => {
const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: '学习', sortBy: 'updatedAt', sortOrder: 'desc' })
expect(result).toHaveLength(2)
expect(result.every(n => n.tags.includes('学习'))).toBe(true)
})
it('returns empty array when no notes match tag', () => {
const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: '不存在', sortBy: 'updatedAt', sortOrder: 'desc' })
expect(result).toHaveLength(0)
})
})
describe('filterNotes — search', () => {
it('filters by title', () => {
const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: 'TypeScript', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' })
expect(result).toHaveLength(1)
expect(result[0].id).toBe('3')
})
it('filters by tag keyword', () => {
const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '读书', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' })
expect(result).toHaveLength(1)
expect(result[0].id).toBe('2')
})
it('search is case-insensitive', () => {
const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: 'typescript', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' })
expect(result).toHaveLength(1)
})
it('returns all notes when search is empty', () => {
const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: ' ', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' })
expect(result).toHaveLength(4)
})
})
describe('filterNotes — sorting', () => {
it('sorts by updatedAt descending', () => {
const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' })
expect(result[0].updatedAt).toBeGreaterThanOrEqual(result[1].updatedAt)
})
it('sorts by updatedAt ascending', () => {
const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: null, sortBy: 'updatedAt', sortOrder: 'asc' })
expect(result[0].updatedAt).toBeLessThanOrEqual(result[1].updatedAt)
})
it('sorts by title ascending', () => {
const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: null, sortBy: 'title', sortOrder: 'asc' })
for (let i = 1; i < result.length; i++) {
expect(result[i - 1].title.localeCompare(result[i].title, 'zh-CN')).toBeLessThanOrEqual(0)
}
})
it('sorts by createdAt descending', () => {
const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: null, sortBy: 'createdAt', sortOrder: 'desc' })
expect(result[0].createdAt).toBeGreaterThanOrEqual(result[1].createdAt)
})
})

84
src/test/utils.test.ts Normal file
View File

@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest'
import { extractTextFromJSON, countWords } from '../lib/utils'
describe('extractTextFromJSON', () => {
it('extracts text from a simple paragraph node', () => {
const json = JSON.stringify({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] },
],
})
expect(extractTextFromJSON(json)).toBe('Hello world')
})
it('extracts and joins text from multiple nodes', () => {
const json = JSON.stringify({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: '第一段' }] },
{ type: 'paragraph', content: [{ type: 'text', text: '第二段' }] },
],
})
const result = extractTextFromJSON(json)
expect(result).toContain('第一段')
expect(result).toContain('第二段')
})
it('handles deeply nested nodes', () => {
const json = JSON.stringify({
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: '列表项' }] },
],
},
],
},
],
})
expect(extractTextFromJSON(json)).toContain('列表项')
})
it('returns empty string on invalid JSON', () => {
expect(extractTextFromJSON('not valid json')).toBe('')
expect(extractTextFromJSON('')).toBe('')
expect(extractTextFromJSON('{}')).toBe('')
})
it('returns empty string for empty doc', () => {
const json = JSON.stringify({ type: 'doc', content: [] })
expect(extractTextFromJSON(json)).toBe('')
})
})
describe('countWords', () => {
it('counts CJK characters individually', () => {
expect(countWords('你好世界')).toBe(4)
})
it('counts English words correctly', () => {
expect(countWords('hello world')).toBe(2)
expect(countWords(' hello world ')).toBe(2)
})
it('handles mixed CJK and English', () => {
// 你好(2) + hello(1) + 世界(2) = 5
expect(countWords('你好 hello 世界')).toBe(5)
})
it('returns 0 for empty string', () => {
expect(countWords('')).toBe(0)
expect(countWords(' ')).toBe(0)
})
it('counts single word', () => {
expect(countWords('hello')).toBe(1)
expect(countWords('你')).toBe(1)
})
})

View File

@ -9,4 +9,21 @@ export default defineConfig({
'/api': 'http://localhost:3001', '/api': 'http://localhost:3001',
}, },
}, },
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
return 'vendor-react'
}
if (id.includes('@tiptap') || id.includes('lowlight') || id.includes('prosemirror')) {
return 'vendor-editor'
}
if (id.includes('framer-motion') || id.includes('@dnd-kit') || id.includes('lucide-react')) {
return 'vendor-ui'
}
},
},
},
},
}) })

7
vitest.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
},
})