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:
parent
aa1430d4bf
commit
9c534a920d
5
.gitignore
vendored
5
.gitignore
vendored
@ -22,3 +22,8 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Secrets & runtime config
|
||||||
|
.env
|
||||||
|
models.json
|
||||||
|
|
||||||
|
|||||||
168
README.md
168
README.md
@ -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 |
|
||||||
|
| 本地存储 | IndexedDB(Dexie.js) |
|
||||||
|
| AI 接入 | Anthropic SDK(claude-sonnet-4-6) |
|
||||||
|
| 后端代理 | Hono(Node.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'
|
- 标题(H1–H4)、段落、引用块、代码块(含语法高亮)
|
||||||
|
- 粗体、斜体、下划线、删除线、行内代码、高亮
|
||||||
|
- 有序/无序列表、任务列表(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
963
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)' : '专注模式'}
|
||||||
|
|||||||
122
src/components/editor/ExportMenu.tsx
Normal file
122
src/components/editor/ExportMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
236
src/lib/export.ts
Normal 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 ``
|
||||||
|
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 `\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
366
src/lib/import.ts
Normal 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) }
|
||||||
|
}
|
||||||
@ -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
159
src/test/store.test.ts
Normal 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
84
src/test/utils.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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
7
vitest.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user