feat(M2): 全文搜索、标签编辑/过滤、排序、笔记重命名、拖拽移动、子文件夹显示
- 新增 extractTextFromJSON,搜索框支持正文全文检索 - 编辑器标题下方新增标签编辑区(添加/删除 chip) - 侧边栏标签 chip 可点击过滤笔记,头部显示激活标签及清除入口 - 笔记列表支持四种排序:最近修改、最近创建、标题 A-Z/Z-A - 右键菜单新增笔记重命名(行内编辑) - @dnd-kit 拖拽笔记到目标文件夹,MouseSensor distance=6 防误触 - 文件夹区改为递归渲染,支持多级嵌套展示与拖拽放入 - updateNote 新增 silent 模式,标签/收藏操作不再更新 updatedAt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7c1f8f8726
commit
56b59269f4
41
package-lock.json
generated
41
package-lock.json
generated
@ -8,6 +8,8 @@
|
|||||||
"name": "program1",
|
"name": "program1",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
@ -307,6 +309,45 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import {
|
|||||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
|
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
|
||||||
Highlighter, List, ListOrdered, Quote,
|
Highlighter, List, ListOrdered, Quote,
|
||||||
Heading1, Heading2, Heading3, Minus, CheckSquare, Table as TableIcon,
|
Heading1, Heading2, Heading3, Minus, CheckSquare, Table as TableIcon,
|
||||||
Type, Star, ImageIcon, Maximize2, Minimize2,
|
Type, Star, ImageIcon, Maximize2, Minimize2, Hash, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
import { countWords } from '../../lib/utils'
|
import { countWords } from '../../lib/utils'
|
||||||
@ -82,6 +82,7 @@ export function Editor() {
|
|||||||
const [title, setTitle] = useState(activeNote?.title ?? '')
|
const [title, setTitle] = useState(activeNote?.title ?? '')
|
||||||
const [wordCount, setWordCount] = useState(0)
|
const [wordCount, setWordCount] = useState(0)
|
||||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved')
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved')
|
||||||
|
const [tagInput, setTagInput] = useState('')
|
||||||
|
|
||||||
const [slashOpen, setSlashOpen] = useState(false)
|
const [slashOpen, setSlashOpen] = useState(false)
|
||||||
const [slashQuery, setSlashQuery] = useState('')
|
const [slashQuery, setSlashQuery] = useState('')
|
||||||
@ -265,6 +266,7 @@ export function Editor() {
|
|||||||
setWordCount(activeNote.wordCount)
|
setWordCount(activeNote.wordCount)
|
||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
setSlashOpen(false)
|
setSlashOpen(false)
|
||||||
|
setTagInput('')
|
||||||
setTimeout(() => { isLoadingRef.current = false }, 100)
|
setTimeout(() => { isLoadingRef.current = false }, 100)
|
||||||
}, [activeNoteId, editor])
|
}, [activeNoteId, editor])
|
||||||
|
|
||||||
@ -318,6 +320,47 @@ export function Editor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tag row */}
|
||||||
|
<div className="px-12 pb-2 max-w-3xl mx-auto w-full">
|
||||||
|
<div className="flex items-center flex-wrap gap-1.5 min-h-[26px]">
|
||||||
|
{activeNote.tags.map(tag => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full"
|
||||||
|
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||||
|
>
|
||||||
|
<Hash size={10} />
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
onMouseDown={e => { e.preventDefault(); updateNote(activeNoteId!, { tags: activeNote.tags.filter(t => t !== tag) }, { silent: true }) }}
|
||||||
|
className="hover:opacity-70 transition-opacity"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
value={tagInput}
|
||||||
|
onChange={e => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
const val = tagInput.trim()
|
||||||
|
if (val && !activeNote.tags.includes(val)) {
|
||||||
|
updateNote(activeNoteId!, { tags: [...activeNote.tags, val] }, { silent: true })
|
||||||
|
}
|
||||||
|
setTagInput('')
|
||||||
|
} else if (e.key === 'Backspace' && tagInput === '' && activeNote.tags.length > 0) {
|
||||||
|
updateNote(activeNoteId!, { tags: activeNote.tags.slice(0, -1) }, { silent: true })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={activeNote.tags.length === 0 ? '添加标签…' : '+'}
|
||||||
|
className="bg-transparent outline-none text-xs"
|
||||||
|
style={{ color: 'var(--text-muted)', minWidth: 60, width: tagInput.length * 8 + 60 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Floating toolbar */}
|
{/* Floating toolbar */}
|
||||||
{editor && (
|
{editor && (
|
||||||
<div style={{ position: 'fixed', zIndex: 100, pointerEvents: 'none' }}>
|
<div style={{ position: 'fixed', zIndex: 100, pointerEvents: 'none' }}>
|
||||||
|
|||||||
@ -3,7 +3,14 @@ import {
|
|||||||
Search, Plus, Star, FileText, Folder, FolderOpen,
|
Search, Plus, Star, FileText, Folder, FolderOpen,
|
||||||
ChevronRight, ChevronDown,
|
ChevronRight, ChevronDown,
|
||||||
Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen,
|
Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen,
|
||||||
|
ArrowUpDown, Check, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
DndContext, DragOverlay, useDraggable, useDroppable,
|
||||||
|
useSensor, useSensors, MouseSensor, TouchSensor,
|
||||||
|
type DragEndEvent, type DragStartEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { useAppStore } from '../../stores/appStore'
|
import { useAppStore } from '../../stores/appStore'
|
||||||
import { formatDate } from '../../lib/utils'
|
import { formatDate } from '../../lib/utils'
|
||||||
import type { Folder as FolderType, Note } from '../../db'
|
import type { Folder as FolderType, Note } from '../../db'
|
||||||
@ -12,8 +19,9 @@ export function Sidebar() {
|
|||||||
const {
|
const {
|
||||||
notes, folders, activeNoteId, activeFolderId, searchQuery,
|
notes, folders, activeNoteId, activeFolderId, searchQuery,
|
||||||
theme, createNote, createFolder, deleteNote, deleteFolder,
|
theme, createNote, createFolder, deleteNote, deleteFolder,
|
||||||
updateFolder, setActiveNote, setActiveFolder, setSearch,
|
updateNote, updateFolder, setActiveNote, setActiveFolder, setSearch,
|
||||||
toggleTheme, toggleStar, filteredNotes,
|
toggleTheme, toggleStar, filteredNotes,
|
||||||
|
activeTag, setActiveTag, sortBy, sortOrder, setSortBy, setSortOrder,
|
||||||
} = useAppStore()
|
} = useAppStore()
|
||||||
|
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
|
||||||
@ -21,27 +29,41 @@ export function Sidebar() {
|
|||||||
const [newFolderName, setNewFolderName] = useState('')
|
const [newFolderName, setNewFolderName] = useState('')
|
||||||
const [creatingFolder, setCreatingFolder] = useState(false)
|
const [creatingFolder, setCreatingFolder] = useState(false)
|
||||||
const [newFolderInput, setNewFolderInput] = useState('')
|
const [newFolderInput, setNewFolderInput] = useState('')
|
||||||
|
const [editingNoteId, setEditingNoteId] = useState<string | null>(null)
|
||||||
|
const [noteEditValue, setNoteEditValue] = useState('')
|
||||||
|
const [sortMenuOpen, setSortMenuOpen] = useState(false)
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
type: 'note' | 'folder'
|
type: 'note' | 'folder'
|
||||||
id: string
|
id: string
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null)
|
||||||
|
|
||||||
const editInputRef = useRef<HTMLInputElement>(null)
|
const sensors = useSensors(
|
||||||
const newFolderRef = useRef<HTMLInputElement>(null)
|
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
|
||||||
|
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const editInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const noteEditRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const newFolderRef = useRef<HTMLInputElement | null>(null)
|
||||||
const displayed = filteredNotes()
|
const displayed = filteredNotes()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingFolderId && editInputRef.current) editInputRef.current.focus()
|
if (editingFolderId && editInputRef.current) editInputRef.current.focus()
|
||||||
}, [editingFolderId])
|
}, [editingFolderId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingNoteId && noteEditRef.current) noteEditRef.current.focus()
|
||||||
|
}, [editingNoteId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (creatingFolder && newFolderRef.current) newFolderRef.current.focus()
|
if (creatingFolder && newFolderRef.current) newFolderRef.current.focus()
|
||||||
}, [creatingFolder])
|
}, [creatingFolder])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const close = () => setContextMenu(null)
|
const close = () => { setContextMenu(null); setSortMenuOpen(false) }
|
||||||
window.addEventListener('click', close)
|
window.addEventListener('click', close)
|
||||||
return () => window.removeEventListener('click', close)
|
return () => window.removeEventListener('click', close)
|
||||||
}, [])
|
}, [])
|
||||||
@ -60,6 +82,11 @@ export function Sidebar() {
|
|||||||
setEditingFolderId(null)
|
setEditingFolderId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRenameNote = async (id: string) => {
|
||||||
|
await updateNote(id, { title: noteEditValue.trim() || '无标题笔记' })
|
||||||
|
setEditingNoteId(null)
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
if (!newFolderInput.trim()) { setCreatingFolder(false); return }
|
if (!newFolderInput.trim()) { setCreatingFolder(false); return }
|
||||||
await createFolder(newFolderInput.trim())
|
await createFolder(newFolderInput.trim())
|
||||||
@ -74,68 +101,27 @@ export function Sidebar() {
|
|||||||
await createNote(folderId)
|
await createNote(folderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootFolders = folders.filter(f => f.parentId === null)
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setDraggingNoteId(event.active.id as string)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
<aside
|
setDraggingNoteId(null)
|
||||||
className="flex flex-col h-full shrink-0 border-r"
|
if (event.over) {
|
||||||
style={{
|
updateNote(event.active.id as string, { folderId: event.over.id as string })
|
||||||
width: 260,
|
}
|
||||||
background: 'var(--bg-subtle)',
|
}
|
||||||
borderColor: 'var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>我的笔记</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button onClick={toggleTheme} className="toolbar-btn" title={theme === 'light' ? '暗色模式' : '亮色模式'}>
|
|
||||||
{theme === 'light' ? <Moon size={15} /> : <Sun size={15} />}
|
|
||||||
</button>
|
|
||||||
<button onClick={handleNewNote} className="toolbar-btn" title="新建笔记">
|
|
||||||
<Plus size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
const draggingNote = draggingNoteId ? notes.find(n => n.id === draggingNoteId) : null
|
||||||
<div className="px-3 py-2">
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 rounded-lg px-3 py-1.5"
|
|
||||||
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)' }}
|
|
||||||
>
|
|
||||||
<Search size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
|
||||||
<input
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder="搜索笔记…"
|
|
||||||
className="bg-transparent outline-none text-sm w-full"
|
|
||||||
style={{ color: 'var(--text)' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nav shortcuts */}
|
const renderFolder = (parentId: string | null, depth: number): React.ReactNode =>
|
||||||
<nav className="px-2 space-y-0.5">
|
folders
|
||||||
<NavItem icon={<BookOpen size={14} />} label="使用指南" active={activeNoteId === '__welcome__'} onClick={() => setActiveNote('__welcome__')} />
|
.filter(f => f.parentId === parentId)
|
||||||
<NavItem icon={<FileText size={14} />} label="所有笔记" count={notes.length} active={activeFolderId === 'all' && activeNoteId !== '__welcome__'} onClick={() => { setActiveFolder('all'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
|
.map(folder => (
|
||||||
<NavItem icon={<Star size={14} />} label="收藏" count={notes.filter(n => n.starred).length} active={activeFolderId === 'starred'} onClick={() => { setActiveFolder('starred'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
|
<div key={folder.id}>
|
||||||
</nav>
|
<DroppableFolderItem
|
||||||
|
|
||||||
{/* Folders section */}
|
|
||||||
<div className="px-3 pt-3 pb-1 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>文件夹</span>
|
|
||||||
<button onClick={() => setCreatingFolder(true)} className="toolbar-btn" title="新建文件夹">
|
|
||||||
<FolderPlus size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-2 space-y-0.5">
|
|
||||||
{rootFolders.map(folder => (
|
|
||||||
<FolderItem
|
|
||||||
key={folder.id}
|
|
||||||
folder={folder}
|
folder={folder}
|
||||||
depth={0}
|
depth={depth}
|
||||||
expanded={expandedFolders.has(folder.id)}
|
expanded={expandedFolders.has(folder.id)}
|
||||||
active={activeFolderId === folder.id}
|
active={activeFolderId === folder.id}
|
||||||
editing={editingFolderId === folder.id}
|
editing={editingFolderId === folder.id}
|
||||||
@ -154,82 +140,218 @@ export function Sidebar() {
|
|||||||
if (e.key === 'Escape') setEditingFolderId(null)
|
if (e.key === 'Escape') setEditingFolderId(null)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
{expandedFolders.has(folder.id) && renderFolder(folder.id, depth + 1)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
|
||||||
{creatingFolder && (
|
const SORT_OPTIONS: { by: typeof sortBy; order: typeof sortOrder; label: string }[] = [
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
|
{ by: 'updatedAt', order: 'desc', label: '最近修改' },
|
||||||
<Folder size={14} style={{ color: 'var(--accent)' }} />
|
{ by: 'createdAt', order: 'desc', label: '最近创建' },
|
||||||
|
{ by: 'title', order: 'asc', label: '标题 A–Z' },
|
||||||
|
{ by: 'title', order: 'desc', label: '标题 Z–A' },
|
||||||
|
]
|
||||||
|
const currentSort = SORT_OPTIONS.find(o => o.by === sortBy && o.order === sortOrder)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
|
<aside
|
||||||
|
className="flex flex-col h-full shrink-0 border-r"
|
||||||
|
style={{ width: 260, background: 'var(--bg-subtle)', borderColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}>我的笔记</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={toggleTheme} className="toolbar-btn" title={theme === 'light' ? '暗色模式' : '亮色模式'}>
|
||||||
|
{theme === 'light' ? <Moon size={15} /> : <Sun size={15} />}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleNewNote} className="toolbar-btn" title="新建笔记">
|
||||||
|
<Plus size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded-lg px-3 py-1.5"
|
||||||
|
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<Search size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
<input
|
<input
|
||||||
ref={newFolderRef}
|
value={searchQuery}
|
||||||
value={newFolderInput}
|
onChange={e => setSearch(e.target.value)}
|
||||||
onChange={e => setNewFolderInput(e.target.value)}
|
placeholder="搜索笔记…"
|
||||||
onBlur={handleCreateFolder}
|
className="bg-transparent outline-none text-sm w-full"
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') handleCreateFolder()
|
|
||||||
if (e.key === 'Escape') { setCreatingFolder(false); setNewFolderInput('') }
|
|
||||||
}}
|
|
||||||
placeholder="文件夹名称"
|
|
||||||
className="bg-transparent outline-none text-sm flex-1"
|
|
||||||
style={{ color: 'var(--text)' }}
|
style={{ color: 'var(--text)' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Note list */}
|
|
||||||
<div className="flex-1 overflow-y-auto mt-2" style={{ borderTop: '1px solid var(--border)' }}>
|
|
||||||
<div className="px-3 py-2 flex items-center sticky top-0 z-10" style={{ background: 'var(--bg-subtle)' }}>
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>
|
|
||||||
笔记{displayed.length > 0 ? ` (${displayed.length})` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="px-2 pb-3 space-y-0.5">
|
|
||||||
{displayed.length === 0 && (
|
{/* Nav shortcuts */}
|
||||||
<div className="text-center py-8" style={{ color: 'var(--text-faint)' }}>
|
<nav className="px-2 space-y-0.5">
|
||||||
<FileText size={24} className="mx-auto mb-2 opacity-40" />
|
<NavItem icon={<BookOpen size={14} />} label="使用指南" active={activeNoteId === '__welcome__'} onClick={() => setActiveNote('__welcome__')} />
|
||||||
<p className="text-xs">暂无笔记</p>
|
<NavItem icon={<FileText size={14} />} label="所有笔记" count={notes.length} active={activeFolderId === 'all' && activeNoteId !== '__welcome__'} onClick={() => { setActiveFolder('all'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
|
||||||
|
<NavItem icon={<Star size={14} />} label="收藏" count={notes.filter(n => n.starred).length} active={activeFolderId === 'starred'} onClick={() => { setActiveFolder('starred'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Folders section */}
|
||||||
|
<div className="px-3 pt-3 pb-1 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>文件夹</span>
|
||||||
|
<button onClick={() => setCreatingFolder(true)} className="toolbar-btn" title="新建文件夹">
|
||||||
|
<FolderPlus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-2 space-y-0.5">
|
||||||
|
{renderFolder(null, 0)}
|
||||||
|
|
||||||
|
{creatingFolder && (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
|
||||||
|
<Folder size={14} style={{ color: 'var(--accent)' }} />
|
||||||
|
<input
|
||||||
|
ref={newFolderRef}
|
||||||
|
value={newFolderInput}
|
||||||
|
onChange={e => setNewFolderInput(e.target.value)}
|
||||||
|
onBlur={handleCreateFolder}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleCreateFolder()
|
||||||
|
if (e.key === 'Escape') { setCreatingFolder(false); setNewFolderInput('') }
|
||||||
|
}}
|
||||||
|
placeholder="文件夹名称"
|
||||||
|
className="bg-transparent outline-none text-sm flex-1"
|
||||||
|
style={{ color: 'var(--text)' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{displayed.map(note => (
|
|
||||||
<NoteItem
|
|
||||||
key={note.id}
|
|
||||||
note={note}
|
|
||||||
active={activeNoteId === note.id}
|
|
||||||
onClick={() => setActiveNote(note.id)}
|
|
||||||
onDelete={() => deleteNote(note.id)}
|
|
||||||
onToggleStar={() => toggleStar(note.id)}
|
|
||||||
onContextMenu={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setContextMenu({ type: 'note', id: note.id, x: e.clientX, y: e.clientY })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Context menu */}
|
{/* Note list */}
|
||||||
{contextMenu && (
|
<div className="flex-1 overflow-y-auto mt-2" style={{ borderTop: '1px solid var(--border)' }}>
|
||||||
<div
|
<div className="px-3 py-2 flex items-center justify-between sticky top-0 z-10" style={{ background: 'var(--bg-subtle)' }}>
|
||||||
className="fixed z-50 rounded-lg py-1 shadow-xl"
|
<div className="flex items-center gap-1.5">
|
||||||
style={{ top: contextMenu.y, left: contextMenu.x, background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 160 }}
|
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>
|
||||||
onClick={e => e.stopPropagation()}
|
笔记{displayed.length > 0 ? ` (${displayed.length})` : ''}
|
||||||
>
|
</span>
|
||||||
{contextMenu.type === 'folder' && (
|
{activeTag && (
|
||||||
<>
|
<span
|
||||||
<CtxItem icon={<Edit2 size={13} />} label="重命名" onClick={() => {
|
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full cursor-pointer"
|
||||||
const f = folders.find(f => f.id === contextMenu.id)
|
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||||
if (f) { setNewFolderName(f.name); setEditingFolderId(contextMenu.id) }
|
onClick={() => setActiveTag(null)}
|
||||||
setContextMenu(null)
|
>
|
||||||
}} />
|
<Hash size={9} />{activeTag}<X size={9} />
|
||||||
<CtxItem icon={<Trash2 size={13} />} label="删除文件夹" danger onClick={async () => { await deleteFolder(contextMenu.id); setContextMenu(null) }} />
|
</span>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
{contextMenu.type === 'note' && (
|
<div className="relative">
|
||||||
<CtxItem icon={<Trash2 size={13} />} label="删除笔记" danger onClick={async () => { await deleteNote(contextMenu.id); setContextMenu(null) }} />
|
<button
|
||||||
)}
|
className="toolbar-btn"
|
||||||
|
title={`排序:${currentSort?.label ?? ''}`}
|
||||||
|
onClick={e => { e.stopPropagation(); setSortMenuOpen(v => !v) }}
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={13} />
|
||||||
|
</button>
|
||||||
|
{sortMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 rounded-lg py-1 z-50 shadow-xl"
|
||||||
|
style={{ background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 140 }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{SORT_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.label}
|
||||||
|
onClick={() => { setSortBy(opt.by); setSortOrder(opt.order); setSortMenuOpen(false) }}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-3 py-1.5 text-sm text-left"
|
||||||
|
style={{ color: opt.by === sortBy && opt.order === sortOrder ? 'var(--accent)' : 'var(--text)' }}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
{opt.by === sortBy && opt.order === sortOrder && <Check size={12} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 pb-3 space-y-0.5">
|
||||||
|
{displayed.length === 0 && (
|
||||||
|
<div className="text-center py-8" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<FileText size={24} className="mx-auto mb-2 opacity-40" />
|
||||||
|
<p className="text-xs">暂无笔记</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{displayed.map(note => (
|
||||||
|
<DraggableNoteItem
|
||||||
|
key={note.id}
|
||||||
|
note={note}
|
||||||
|
active={activeNoteId === note.id}
|
||||||
|
editing={editingNoteId === note.id}
|
||||||
|
editValue={noteEditValue}
|
||||||
|
editRef={noteEditRef}
|
||||||
|
activeTag={activeTag}
|
||||||
|
onEditChange={setNoteEditValue}
|
||||||
|
onEditBlur={() => handleRenameNote(note.id)}
|
||||||
|
onEditKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleRenameNote(note.id)
|
||||||
|
if (e.key === 'Escape') setEditingNoteId(null)
|
||||||
|
}}
|
||||||
|
onClick={() => setActiveNote(note.id)}
|
||||||
|
onDelete={() => deleteNote(note.id)}
|
||||||
|
onToggleStar={() => toggleStar(note.id)}
|
||||||
|
onTagClick={(tag) => setActiveTag(activeTag === tag ? null : tag)}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setContextMenu({ type: 'note', id: note.id, x: e.clientX, y: e.clientY })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</aside>
|
{/* Context menu */}
|
||||||
|
{contextMenu && (
|
||||||
|
<div
|
||||||
|
className="fixed z-50 rounded-lg py-1 shadow-xl"
|
||||||
|
style={{ top: contextMenu.y, left: contextMenu.x, background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 160 }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{contextMenu.type === 'folder' && (
|
||||||
|
<>
|
||||||
|
<CtxItem icon={<Edit2 size={13} />} label="重命名" onClick={() => {
|
||||||
|
const f = folders.find(f => f.id === contextMenu.id)
|
||||||
|
if (f) { setNewFolderName(f.name); setEditingFolderId(contextMenu.id) }
|
||||||
|
setContextMenu(null)
|
||||||
|
}} />
|
||||||
|
<CtxItem icon={<Trash2 size={13} />} label="删除文件夹" danger onClick={async () => { await deleteFolder(contextMenu.id); setContextMenu(null) }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{contextMenu.type === 'note' && (
|
||||||
|
<>
|
||||||
|
<CtxItem icon={<Edit2 size={13} />} label="重命名" onClick={() => {
|
||||||
|
const n = notes.find(n => n.id === contextMenu.id)
|
||||||
|
if (n) { setNoteEditValue(n.title); setEditingNoteId(contextMenu.id) }
|
||||||
|
setContextMenu(null)
|
||||||
|
}} />
|
||||||
|
<CtxItem icon={<Trash2 size={13} />} label="删除笔记" danger onClick={async () => { await deleteNote(contextMenu.id); setContextMenu(null) }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Drag overlay */}
|
||||||
|
<DragOverlay>
|
||||||
|
{draggingNote && (
|
||||||
|
<div
|
||||||
|
className="px-2 py-2 rounded-lg text-sm font-medium opacity-90 shadow-lg"
|
||||||
|
style={{ background: 'var(--bg)', border: '1px solid var(--accent)', color: 'var(--text)', width: 220 }}
|
||||||
|
>
|
||||||
|
{draggingNote.title || '无标题笔记'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,16 +368,25 @@ function NavItem({ icon, label, count, active, onClick }: { icon: React.ReactNod
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderItem({ folder, depth, expanded, active, editing, editValue, editRef, onToggle, onClick, onContextMenu, onEditChange, onEditBlur, onEditKeyDown }: {
|
function DroppableFolderItem({ folder, depth, expanded, active, editing, editValue, editRef, onToggle, onClick, onContextMenu, onEditChange, onEditBlur, onEditKeyDown }: {
|
||||||
folder: FolderType; depth: number; expanded: boolean; active: boolean; editing: boolean
|
folder: FolderType; depth: number; expanded: boolean; active: boolean; editing: boolean
|
||||||
editValue: string; editRef: React.RefObject<HTMLInputElement | null>
|
editValue: string; editRef: React.RefObject<HTMLInputElement | null>
|
||||||
onToggle: () => void; onClick: () => void; onContextMenu: (e: React.MouseEvent) => void
|
onToggle: () => void; onClick: () => void; onContextMenu: (e: React.MouseEvent) => void
|
||||||
onEditChange: (v: string) => void; onEditBlur: () => void; onEditKeyDown: (e: React.KeyboardEvent) => void
|
onEditChange: (v: string) => void; onEditBlur: () => void; onEditKeyDown: (e: React.KeyboardEvent) => void
|
||||||
}) {
|
}) {
|
||||||
|
const { isOver, setNodeRef } = useDroppable({ id: folder.id })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
className="flex items-center gap-1.5 py-1.5 rounded-lg cursor-pointer text-sm"
|
className="flex items-center gap-1.5 py-1.5 rounded-lg cursor-pointer text-sm"
|
||||||
style={{ paddingLeft: 8 + depth * 16, background: active ? 'var(--accent-subtle)' : 'transparent', color: active ? 'var(--accent)' : 'var(--text-muted)' }}
|
style={{
|
||||||
|
paddingLeft: 8 + depth * 16,
|
||||||
|
background: isOver ? 'var(--accent-subtle)' : active ? 'var(--accent-subtle)' : 'transparent',
|
||||||
|
color: active || isOver ? 'var(--accent)' : 'var(--text-muted)',
|
||||||
|
outline: isOver ? '2px solid var(--accent)' : 'none',
|
||||||
|
outlineOffset: -2,
|
||||||
|
}}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
>
|
>
|
||||||
@ -286,34 +417,66 @@ function CtxItem({ icon, label, danger, onClick }: { icon: React.ReactNode; labe
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoteItem({ note, active, onClick, onDelete, onToggleStar, onContextMenu }: {
|
function DraggableNoteItem({ note, active, editing, editValue, editRef, activeTag, onEditChange, onEditBlur, onEditKeyDown, onClick, onDelete, onToggleStar, onTagClick, onContextMenu }: {
|
||||||
note: Note
|
note: Note
|
||||||
active: boolean
|
active: boolean
|
||||||
|
editing: boolean
|
||||||
|
editValue: string
|
||||||
|
editRef: React.RefObject<HTMLInputElement | null>
|
||||||
|
activeTag: string | null
|
||||||
|
onEditChange: (v: string) => void
|
||||||
|
onEditBlur: () => void
|
||||||
|
onEditKeyDown: (e: React.KeyboardEvent) => void
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onToggleStar: () => void
|
onToggleStar: () => void
|
||||||
|
onTagClick: (tag: string) => void
|
||||||
onContextMenu: (e: React.MouseEvent) => void
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
}) {
|
}) {
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: note.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="px-2 py-2 rounded-lg cursor-pointer relative"
|
ref={setNodeRef}
|
||||||
style={{
|
style={{
|
||||||
|
...style,
|
||||||
background: active ? 'var(--accent-subtle)' : hovered ? 'var(--bg-muted)' : 'transparent',
|
background: active ? 'var(--accent-subtle)' : hovered ? 'var(--bg-muted)' : 'transparent',
|
||||||
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
|
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||||
}}
|
}}
|
||||||
|
className="px-2 py-2 rounded-lg relative"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-1">
|
<div className="flex items-start justify-between gap-1">
|
||||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
{note.starred && <Star size={11} style={{ color: '#f59e0b', flexShrink: 0 }} fill="currentColor" />}
|
{note.starred && <Star size={11} style={{ color: '#f59e0b', flexShrink: 0 }} fill="currentColor" />}
|
||||||
<span className="text-sm font-medium truncate" style={{ color: active ? 'var(--accent)' : 'var(--text)' }}>
|
{editing ? (
|
||||||
{note.title || '无标题笔记'}
|
<input
|
||||||
</span>
|
ref={editRef}
|
||||||
|
value={editValue}
|
||||||
|
onChange={e => onEditChange(e.target.value)}
|
||||||
|
onBlur={onEditBlur}
|
||||||
|
onKeyDown={onEditKeyDown}
|
||||||
|
className="bg-transparent outline-none text-sm font-medium flex-1 min-w-0"
|
||||||
|
style={{ color: 'var(--text)' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium truncate" style={{ color: active ? 'var(--accent)' : 'var(--text)' }}>
|
||||||
|
{note.title || '无标题笔记'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hovered ? (
|
{hovered ? (
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
@ -345,8 +508,15 @@ function NoteItem({ note, active, onClick, onDelete, onToggleStar, onContextMenu
|
|||||||
{note.tags.length > 0 && (
|
{note.tags.length > 0 && (
|
||||||
<div className="flex gap-1 mt-1 flex-wrap">
|
<div className="flex gap-1 mt-1 flex-wrap">
|
||||||
{note.tags.slice(0, 3).map(tag => (
|
{note.tags.slice(0, 3).map(tag => (
|
||||||
<span key={tag} className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full"
|
<span
|
||||||
style={{ background: 'var(--bg-muted)', color: 'var(--text-muted)' }}>
|
key={tag}
|
||||||
|
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
background: activeTag === tag ? 'var(--accent-subtle)' : 'var(--bg-muted)',
|
||||||
|
color: activeTag === tag ? 'var(--accent)' : 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
onClick={e => { e.stopPropagation(); onTagClick(tag) }}
|
||||||
|
>
|
||||||
<Hash size={9} />{tag}
|
<Hash size={9} />{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -20,6 +20,21 @@ export function formatDate(timestamp: number) {
|
|||||||
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function collectText(node: any): string {
|
||||||
|
if (typeof node.text === 'string') return node.text
|
||||||
|
if (Array.isArray(node.content)) return node.content.map(collectText).join(' ')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTextFromJSON(content: string): string {
|
||||||
|
try {
|
||||||
|
return collectText(JSON.parse(content))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function countWords(text: string) {
|
export function countWords(text: string) {
|
||||||
const trimmed = text.trim()
|
const trimmed = text.trim()
|
||||||
if (!trimmed) return 0
|
if (!trimmed) return 0
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { db, type Note, type Folder } from '../db'
|
import { db, type Note, type Folder } from '../db'
|
||||||
import { generateId } from '../lib/utils'
|
import { generateId, extractTextFromJSON } from '../lib/utils'
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
notes: Note[]
|
notes: Note[]
|
||||||
@ -10,11 +10,14 @@ interface AppState {
|
|||||||
searchQuery: string
|
searchQuery: string
|
||||||
theme: 'light' | 'dark'
|
theme: 'light' | 'dark'
|
||||||
focusMode: boolean
|
focusMode: boolean
|
||||||
|
activeTag: string | null
|
||||||
|
sortBy: 'updatedAt' | 'createdAt' | 'title'
|
||||||
|
sortOrder: 'asc' | 'desc'
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
loadAll: () => Promise<void>
|
loadAll: () => Promise<void>
|
||||||
createNote: (folderId?: string | null) => Promise<string>
|
createNote: (folderId?: string | null) => Promise<string>
|
||||||
updateNote: (id: string, patch: Partial<Note>) => Promise<void>
|
updateNote: (id: string, patch: Partial<Note>, opts?: { silent?: boolean }) => Promise<void>
|
||||||
deleteNote: (id: string) => Promise<void>
|
deleteNote: (id: string) => Promise<void>
|
||||||
toggleStar: (id: string) => Promise<void>
|
toggleStar: (id: string) => Promise<void>
|
||||||
|
|
||||||
@ -27,6 +30,9 @@ interface AppState {
|
|||||||
setSearch: (q: string) => void
|
setSearch: (q: string) => void
|
||||||
toggleTheme: () => void
|
toggleTheme: () => void
|
||||||
toggleFocusMode: () => void
|
toggleFocusMode: () => void
|
||||||
|
setActiveTag: (tag: string | null) => void
|
||||||
|
setSortBy: (by: 'updatedAt' | 'createdAt' | 'title') => void
|
||||||
|
setSortOrder: (order: 'asc' | 'desc') => void
|
||||||
|
|
||||||
filteredNotes: () => Note[]
|
filteredNotes: () => Note[]
|
||||||
}
|
}
|
||||||
@ -39,6 +45,9 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light',
|
theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light',
|
||||||
focusMode: false,
|
focusMode: false,
|
||||||
|
activeTag: null,
|
||||||
|
sortBy: 'updatedAt',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
|
||||||
loadAll: async () => {
|
loadAll: async () => {
|
||||||
const [notes, folders] = await Promise.all([
|
const [notes, folders] = await Promise.all([
|
||||||
@ -72,12 +81,14 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
return id
|
return id
|
||||||
},
|
},
|
||||||
|
|
||||||
updateNote: async (id, patch) => {
|
updateNote: async (id, patch, opts) => {
|
||||||
|
const silent = opts?.silent ?? false
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
await db.notes.update(id, { ...patch, updatedAt: now })
|
const dbPatch = silent ? patch : { ...patch, updatedAt: now }
|
||||||
|
await db.notes.update(id, dbPatch)
|
||||||
set(s => ({
|
set(s => ({
|
||||||
notes: s.notes.map(n =>
|
notes: s.notes.map(n =>
|
||||||
n.id === id ? { ...n, ...patch, updatedAt: now } : n
|
n.id === id ? { ...n, ...dbPatch } : n
|
||||||
).sort((a, b) => b.updatedAt - a.updatedAt),
|
).sort((a, b) => b.updatedAt - a.updatedAt),
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
@ -94,7 +105,7 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
toggleStar: async (id) => {
|
toggleStar: async (id) => {
|
||||||
const note = get().notes.find(n => n.id === id)
|
const note = get().notes.find(n => n.id === id)
|
||||||
if (!note) return
|
if (!note) return
|
||||||
await get().updateNote(id, { starred: !note.starred })
|
await get().updateNote(id, { starred: !note.starred }, { silent: true })
|
||||||
},
|
},
|
||||||
|
|
||||||
createFolder: async (name, parentId = null) => {
|
createFolder: async (name, parentId = null) => {
|
||||||
@ -132,9 +143,12 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggleFocusMode: () => set(s => ({ focusMode: !s.focusMode })),
|
toggleFocusMode: () => set(s => ({ focusMode: !s.focusMode })),
|
||||||
|
setActiveTag: (tag) => set({ activeTag: tag }),
|
||||||
|
setSortBy: (by) => set({ sortBy: by }),
|
||||||
|
setSortOrder: (order) => set({ sortOrder: order }),
|
||||||
|
|
||||||
filteredNotes: () => {
|
filteredNotes: () => {
|
||||||
const { notes, activeFolderId, searchQuery } = get()
|
const { notes, activeFolderId, searchQuery, activeTag, sortBy, sortOrder } = get()
|
||||||
let result = notes
|
let result = notes
|
||||||
|
|
||||||
if (activeFolderId === 'starred') {
|
if (activeFolderId === 'starred') {
|
||||||
@ -143,14 +157,29 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
result = result.filter(n => n.folderId === activeFolderId)
|
result = result.filter(n => n.folderId === activeFolderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeTag) {
|
||||||
|
result = result.filter(n => n.tags.includes(activeTag))
|
||||||
|
}
|
||||||
|
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
const q = searchQuery.toLowerCase()
|
const q = searchQuery.toLowerCase()
|
||||||
result = result.filter(n =>
|
result = result.filter(n =>
|
||||||
n.title.toLowerCase().includes(q) ||
|
n.title.toLowerCase().includes(q) ||
|
||||||
n.tags.some(t => t.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
|
return result
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user