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,16 +101,62 @@ 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)
}
const handleDragEnd = (event: DragEndEvent) => {
setDraggingNoteId(null)
if (event.over) {
updateNote(event.active.id as string, { folderId: event.over.id as string })
}
}
const draggingNote = draggingNoteId ? notes.find(n => n.id === draggingNoteId) : null
const renderFolder = (parentId: string | null, depth: number): React.ReactNode =>
folders
.filter(f => f.parentId === parentId)
.map(folder => (
<div key={folder.id}>
<DroppableFolderItem
folder={folder}
depth={depth}
expanded={expandedFolders.has(folder.id)}
active={activeFolderId === folder.id}
editing={editingFolderId === folder.id}
editValue={newFolderName}
editRef={editInputRef}
onToggle={() => toggleFolder(folder.id)}
onClick={() => setActiveFolder(folder.id)}
onContextMenu={(e) => {
e.preventDefault()
setContextMenu({ type: 'folder', id: folder.id, x: e.clientX, y: e.clientY })
}}
onEditChange={setNewFolderName}
onEditBlur={() => handleRenameFolder(folder.id)}
onEditKeyDown={(e) => {
if (e.key === 'Enter') handleRenameFolder(folder.id)
if (e.key === 'Escape') setEditingFolderId(null)
}}
/>
{expandedFolders.has(folder.id) && renderFolder(folder.id, depth + 1)}
</div>
))
const SORT_OPTIONS: { by: typeof sortBy; order: typeof sortOrder; label: string }[] = [
{ by: 'updatedAt', order: 'desc', label: '最近修改' },
{ 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 ( return (
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<aside <aside
className="flex flex-col h-full shrink-0 border-r" className="flex flex-col h-full shrink-0 border-r"
style={{ style={{ width: 260, background: 'var(--bg-subtle)', borderColor: 'var(--border)' }}
width: 260,
background: 'var(--bg-subtle)',
borderColor: 'var(--border)',
}}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border)' }}> <div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
@ -131,30 +204,7 @@ export function Sidebar() {
</div> </div>
<div className="px-2 space-y-0.5"> <div className="px-2 space-y-0.5">
{rootFolders.map(folder => ( {renderFolder(null, 0)}
<FolderItem
key={folder.id}
folder={folder}
depth={0}
expanded={expandedFolders.has(folder.id)}
active={activeFolderId === folder.id}
editing={editingFolderId === folder.id}
editValue={newFolderName}
editRef={editInputRef}
onToggle={() => toggleFolder(folder.id)}
onClick={() => setActiveFolder(folder.id)}
onContextMenu={(e) => {
e.preventDefault()
setContextMenu({ type: 'folder', id: folder.id, x: e.clientX, y: e.clientY })
}}
onEditChange={setNewFolderName}
onEditBlur={() => handleRenameFolder(folder.id)}
onEditKeyDown={(e) => {
if (e.key === 'Enter') handleRenameFolder(folder.id)
if (e.key === 'Escape') setEditingFolderId(null)
}}
/>
))}
{creatingFolder && ( {creatingFolder && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg" style={{ background: 'var(--bg-muted)' }}> <div className="flex items-center gap-2 px-2 py-1.5 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
@ -178,10 +228,51 @@ export function Sidebar() {
{/* Note list */} {/* Note list */}
<div className="flex-1 overflow-y-auto mt-2" style={{ borderTop: '1px solid var(--border)' }}> <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)' }}> <div className="px-3 py-2 flex items-center justify-between sticky top-0 z-10" style={{ background: 'var(--bg-subtle)' }}>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}> <span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>
{displayed.length > 0 ? ` (${displayed.length})` : ''} {displayed.length > 0 ? ` (${displayed.length})` : ''}
</span> </span>
{activeTag && (
<span
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full cursor-pointer"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
onClick={() => setActiveTag(null)}
>
<Hash size={9} />{activeTag}<X size={9} />
</span>
)}
</div>
<div className="relative">
<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>
<div className="px-2 pb-3 space-y-0.5"> <div className="px-2 pb-3 space-y-0.5">
{displayed.length === 0 && ( {displayed.length === 0 && (
@ -191,13 +282,24 @@ export function Sidebar() {
</div> </div>
)} )}
{displayed.map(note => ( {displayed.map(note => (
<NoteItem <DraggableNoteItem
key={note.id} key={note.id}
note={note} note={note}
active={activeNoteId === note.id} 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)} onClick={() => setActiveNote(note.id)}
onDelete={() => deleteNote(note.id)} onDelete={() => deleteNote(note.id)}
onToggleStar={() => toggleStar(note.id)} onToggleStar={() => toggleStar(note.id)}
onTagClick={(tag) => setActiveTag(activeTag === tag ? null : tag)}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault() e.preventDefault()
setContextMenu({ type: 'note', id: note.id, x: e.clientX, y: e.clientY }) setContextMenu({ type: 'note', id: note.id, x: e.clientX, y: e.clientY })
@ -225,11 +327,31 @@ export function Sidebar() {
</> </>
)} )}
{contextMenu.type === 'note' && ( {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) }} /> <CtxItem icon={<Trash2 size={13} />} label="删除笔记" danger onClick={async () => { await deleteNote(contextMenu.id); setContextMenu(null) }} />
</>
)} )}
</div> </div>
)} )}
</aside> </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" />}
{editing ? (
<input
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)' }}> <span className="text-sm font-medium truncate" style={{ color: active ? 'var(--accent)' : 'var(--text)' }}>
{note.title || '无标题笔记'} {note.title || '无标题笔记'}
</span> </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
}, },
})) }))