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:
co-authored by
Claude Sonnet 4.6
parent
aa1430d4bf
commit
9c534a920d
@@ -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)' : '专注模式'}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
+18
-8
@@ -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
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user