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:
MikiVL 2026-05-02 20:07:31 +08:00
parent 7c1f8f8726
commit 56b59269f4
6 changed files with 445 additions and 145 deletions

41
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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' }}>

View File

@ -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: '标题 AZ' },
{ by: 'title', order: 'desc', label: '标题 ZA' },
]
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>
))} ))}

View File

@ -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

View File

@ -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
}, },
})) }))