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
|
||||
*.sln
|
||||
*.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
|
||||
export default defineConfig([
|
||||
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...
|
||||
},
|
||||
},
|
||||
])
|
||||
# 4. 启动开发服务器(同时启动前端 + AI 代理)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
studynote/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── editor/ # TipTap 编辑器、浮动工具栏、欢迎页
|
||||
│ │ ├── sidebar/ # 左侧导航栏
|
||||
│ │ └── ai/ # AI 面板、模型管理弹窗
|
||||
│ ├── stores/ # Zustand store
|
||||
│ ├── db/ # Dexie.js IndexedDB 封装
|
||||
│ ├── lib/ # 工具函数、AI 流式请求
|
||||
│ └── test/ # Vitest 单元测试
|
||||
├── server/ # Hono AI 代理服务
|
||||
├── .env.example # 环境变量模板
|
||||
└── vite.config.ts
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
复制 `.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",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.92.0",
|
||||
@ -48,11 +50,14 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.4.2",
|
||||
"docx": "^9.6.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"framer-motion": "^12.38.0",
|
||||
"hono": "^4.12.16",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@ -76,6 +81,7 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~6.0.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 { streamAI } from '../../lib/ai'
|
||||
import { WelcomeView } from './WelcomeView'
|
||||
import { ExportMenu } from './ExportMenu'
|
||||
|
||||
const lowlight = createLowlight(common)
|
||||
|
||||
@ -385,6 +386,9 @@ export function Editor() {
|
||||
style={{ color: activeNote.starred ? '#f59e0b' : 'var(--text-faint)' }}
|
||||
/>
|
||||
</button>
|
||||
<div className="shrink-0 mt-1.5">
|
||||
<ExportMenu title={title} content={activeNote.content} />
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleFocusMode}
|
||||
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,
|
||||
ChevronRight, ChevronDown,
|
||||
Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen,
|
||||
ArrowUpDown, Check, X, Bot,
|
||||
ArrowUpDown, Check, X, Bot, Upload,
|
||||
} from 'lucide-react'
|
||||
import { ModelSettingsModal } from '../ai/ModelSettingsModal'
|
||||
import { importMarkdown, importTxt, importDocx, importPDF } from '../../lib/import'
|
||||
import {
|
||||
DndContext, DragOverlay, useDraggable, useDroppable,
|
||||
useSensor, useSensors, MouseSensor, TouchSensor,
|
||||
@ -35,6 +36,7 @@ export function Sidebar() {
|
||||
const [noteEditValue, setNoteEditValue] = useState('')
|
||||
const [sortMenuOpen, setSortMenuOpen] = useState(false)
|
||||
const [modelModalOpen, setModelModalOpen] = useState(false)
|
||||
const [localSearch, setLocalSearch] = useState(searchQuery)
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
type: 'note' | 'folder'
|
||||
id: string
|
||||
@ -53,6 +55,11 @@ export function Sidebar() {
|
||||
const newFolderRef = useRef<HTMLInputElement | null>(null)
|
||||
const displayed = filteredNotes()
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearch(localSearch), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [localSearch, setSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingFolderId && editInputRef.current) editInputRef.current.focus()
|
||||
}, [editingFolderId])
|
||||
@ -104,6 +111,31 @@ export function Sidebar() {
|
||||
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) => {
|
||||
setDraggingNoteId(event.active.id as string)
|
||||
}
|
||||
@ -182,8 +214,8 @@ export function Sidebar() {
|
||||
>
|
||||
<Search size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
value={localSearch}
|
||||
onChange={e => setLocalSearch(e.target.value)}
|
||||
placeholder="搜索笔记…"
|
||||
className="bg-transparent outline-none text-sm w-full"
|
||||
style={{ color: 'var(--text)' }}
|
||||
@ -356,6 +388,24 @@ export function Sidebar() {
|
||||
<Bot size={13} />
|
||||
AI 模型
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
|
||||
@ -285,3 +285,28 @@
|
||||
border-radius: 1px;
|
||||
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
|
||||
sortBy: 'updatedAt' | 'createdAt' | 'title'
|
||||
sortOrder: 'asc' | 'desc'
|
||||
_notesVersion: number
|
||||
_filteredCache: { key: string; result: Note[] } | null
|
||||
|
||||
// actions
|
||||
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>
|
||||
deleteNote: (id: string) => Promise<void>
|
||||
toggleStar: (id: string) => Promise<void>
|
||||
@ -51,13 +53,15 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
activeTag: null,
|
||||
sortBy: 'updatedAt',
|
||||
sortOrder: 'desc',
|
||||
_notesVersion: 0,
|
||||
_filteredCache: null,
|
||||
|
||||
loadAll: async () => {
|
||||
const [notes, folders] = await Promise.all([
|
||||
db.notes.orderBy('updatedAt').reverse().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
|
||||
const cur = get().activeNoteId
|
||||
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 now = Date.now()
|
||||
const note: Note = {
|
||||
id,
|
||||
title: '无标题笔记',
|
||||
content: JSON.stringify({ type: 'doc', content: [{ type: 'paragraph' }] }),
|
||||
title: init?.title ?? '无标题笔记',
|
||||
content: init?.content ?? JSON.stringify({ type: 'doc', content: [{ type: 'paragraph' }] }),
|
||||
folderId: folderId ?? get().activeFolderId as string | null,
|
||||
tags: [],
|
||||
starred: false,
|
||||
@ -80,7 +84,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
wordCount: 0,
|
||||
}
|
||||
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
|
||||
},
|
||||
|
||||
@ -93,6 +97,8 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
notes: s.notes.map(n =>
|
||||
n.id === id ? { ...n, ...dbPatch } : n
|
||||
).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 => {
|
||||
const notes = s.notes.filter(n => n.id !== id)
|
||||
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 }),
|
||||
|
||||
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
|
||||
|
||||
if (activeFolderId === 'starred') {
|
||||
@ -184,6 +193,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
return sortOrder === 'asc' ? cmp : -cmp
|
||||
})
|
||||
|
||||
set({ _filteredCache: { key: cacheKey, 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',
|
||||
},
|
||||
},
|
||||
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