From 56b59269f49eee25977502a4803846fe076ae507 Mon Sep 17 00:00:00 2001 From: MikiVL Date: Sat, 2 May 2026 20:07:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(M2):=20=E5=85=A8=E6=96=87=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E3=80=81=E6=A0=87=E7=AD=BE=E7=BC=96=E8=BE=91/?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E3=80=81=E6=8E=92=E5=BA=8F=E3=80=81=E7=AC=94?= =?UTF-8?q?=E8=AE=B0=E9=87=8D=E5=91=BD=E5=90=8D=E3=80=81=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E3=80=81=E5=AD=90=E6=96=87=E4=BB=B6=E5=A4=B9?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 extractTextFromJSON,搜索框支持正文全文检索 - 编辑器标题下方新增标签编辑区(添加/删除 chip) - 侧边栏标签 chip 可点击过滤笔记,头部显示激活标签及清除入口 - 笔记列表支持四种排序:最近修改、最近创建、标题 A-Z/Z-A - 右键菜单新增笔记重命名(行内编辑) - @dnd-kit 拖拽笔记到目标文件夹,MouseSensor distance=6 防误触 - 文件夹区改为递归渲染,支持多级嵌套展示与拖拽放入 - updateNote 新增 silent 模式,标签/收藏操作不再更新 updatedAt Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 41 +++ package.json | 2 + src/components/editor/Editor.tsx | 45 ++- src/components/sidebar/Sidebar.tsx | 442 ++++++++++++++++++++--------- src/lib/utils.ts | 15 + src/stores/appStore.ts | 45 ++- 6 files changed, 445 insertions(+), 145 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e29445..d4e90c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "program1", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", @@ -307,6 +309,45 @@ "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": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", diff --git a/package.json b/package.json index 62d6425..ac9da1b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 28f3ab0..433ffee 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -22,7 +22,7 @@ import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code, Highlighter, List, ListOrdered, Quote, Heading1, Heading2, Heading3, Minus, CheckSquare, Table as TableIcon, - Type, Star, ImageIcon, Maximize2, Minimize2, + Type, Star, ImageIcon, Maximize2, Minimize2, Hash, X, } from 'lucide-react' import { useAppStore } from '../../stores/appStore' import { countWords } from '../../lib/utils' @@ -82,6 +82,7 @@ export function Editor() { const [title, setTitle] = useState(activeNote?.title ?? '') const [wordCount, setWordCount] = useState(0) const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved') + const [tagInput, setTagInput] = useState('') const [slashOpen, setSlashOpen] = useState(false) const [slashQuery, setSlashQuery] = useState('') @@ -265,6 +266,7 @@ export function Editor() { setWordCount(activeNote.wordCount) setSaveStatus('saved') setSlashOpen(false) + setTagInput('') setTimeout(() => { isLoadingRef.current = false }, 100) }, [activeNoteId, editor]) @@ -318,6 +320,47 @@ export function Editor() { + {/* Tag row */} +
+
+ {activeNote.tags.map(tag => ( + + + {tag} + + + ))} + 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 }} + /> +
+
+ {/* Floating toolbar */} {editor && (
diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 24e0690..e5708af 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -3,7 +3,14 @@ import { Search, Plus, Star, FileText, Folder, FolderOpen, ChevronRight, ChevronDown, Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen, + ArrowUpDown, Check, X, } 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 { formatDate } from '../../lib/utils' import type { Folder as FolderType, Note } from '../../db' @@ -12,8 +19,9 @@ export function Sidebar() { const { notes, folders, activeNoteId, activeFolderId, searchQuery, theme, createNote, createFolder, deleteNote, deleteFolder, - updateFolder, setActiveNote, setActiveFolder, setSearch, + updateNote, updateFolder, setActiveNote, setActiveFolder, setSearch, toggleTheme, toggleStar, filteredNotes, + activeTag, setActiveTag, sortBy, sortOrder, setSortBy, setSortOrder, } = useAppStore() const [expandedFolders, setExpandedFolders] = useState>(new Set()) @@ -21,27 +29,41 @@ export function Sidebar() { const [newFolderName, setNewFolderName] = useState('') const [creatingFolder, setCreatingFolder] = useState(false) const [newFolderInput, setNewFolderInput] = useState('') + const [editingNoteId, setEditingNoteId] = useState(null) + const [noteEditValue, setNoteEditValue] = useState('') + const [sortMenuOpen, setSortMenuOpen] = useState(false) const [contextMenu, setContextMenu] = useState<{ type: 'note' | 'folder' id: string x: number y: number } | null>(null) + const [draggingNoteId, setDraggingNoteId] = useState(null) - const editInputRef = useRef(null) - const newFolderRef = useRef(null) + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 6 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }), + ) + + const editInputRef = useRef(null) + const noteEditRef = useRef(null) + const newFolderRef = useRef(null) const displayed = filteredNotes() useEffect(() => { if (editingFolderId && editInputRef.current) editInputRef.current.focus() }, [editingFolderId]) + useEffect(() => { + if (editingNoteId && noteEditRef.current) noteEditRef.current.focus() + }, [editingNoteId]) + useEffect(() => { if (creatingFolder && newFolderRef.current) newFolderRef.current.focus() }, [creatingFolder]) useEffect(() => { - const close = () => setContextMenu(null) + const close = () => { setContextMenu(null); setSortMenuOpen(false) } window.addEventListener('click', close) return () => window.removeEventListener('click', close) }, []) @@ -60,6 +82,11 @@ export function Sidebar() { setEditingFolderId(null) } + const handleRenameNote = async (id: string) => { + await updateNote(id, { title: noteEditValue.trim() || '无标题笔记' }) + setEditingNoteId(null) + } + const handleCreateFolder = async () => { if (!newFolderInput.trim()) { setCreatingFolder(false); return } await createFolder(newFolderInput.trim()) @@ -74,68 +101,27 @@ export function Sidebar() { await createNote(folderId) } - const rootFolders = folders.filter(f => f.parentId === null) + const handleDragStart = (event: DragStartEvent) => { + setDraggingNoteId(event.active.id as string) + } - return ( - + + {/* Drag overlay */} + + {draggingNote && ( +
+ {draggingNote.title || '无标题笔记'} +
+ )} +
+ ) } @@ -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 editValue: string; editRef: React.RefObject onToggle: () => void; onClick: () => void; onContextMenu: (e: React.MouseEvent) => void onEditChange: (v: string) => void; onEditBlur: () => void; onEditKeyDown: (e: React.KeyboardEvent) => void }) { + const { isOver, setNodeRef } = useDroppable({ id: folder.id }) + return (
@@ -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 active: boolean + editing: boolean + editValue: string + editRef: React.RefObject + activeTag: string | null + onEditChange: (v: string) => void + onEditBlur: () => void + onEditKeyDown: (e: React.KeyboardEvent) => void onClick: () => void onDelete: () => void onToggleStar: () => void + onTagClick: (tag: string) => void onContextMenu: (e: React.MouseEvent) => void }) { 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 (
setHovered(true)} onMouseLeave={() => setHovered(false)} + {...attributes} + {...listeners} >
{note.starred && } - - {note.title || '无标题笔记'} - + {editing ? ( + 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()} + /> + ) : ( + + {note.title || '无标题笔记'} + + )}
{hovered ? (
@@ -345,8 +508,15 @@ function NoteItem({ note, active, onClick, onDelete, onToggleStar, onContextMenu {note.tags.length > 0 && (
{note.tags.slice(0, 3).map(tag => ( - + { e.stopPropagation(); onTagClick(tag) }} + > {tag} ))} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 897aeb3..40035dd 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -20,6 +20,21 @@ export function formatDate(timestamp: number) { 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) { const trimmed = text.trim() if (!trimmed) return 0 diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 1dd1ebd..4f2f07c 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { db, type Note, type Folder } from '../db' -import { generateId } from '../lib/utils' +import { generateId, extractTextFromJSON } from '../lib/utils' interface AppState { notes: Note[] @@ -10,11 +10,14 @@ interface AppState { searchQuery: string theme: 'light' | 'dark' focusMode: boolean + activeTag: string | null + sortBy: 'updatedAt' | 'createdAt' | 'title' + sortOrder: 'asc' | 'desc' // actions loadAll: () => Promise createNote: (folderId?: string | null) => Promise - updateNote: (id: string, patch: Partial) => Promise + updateNote: (id: string, patch: Partial, opts?: { silent?: boolean }) => Promise deleteNote: (id: string) => Promise toggleStar: (id: string) => Promise @@ -27,6 +30,9 @@ interface AppState { setSearch: (q: string) => void toggleTheme: () => void toggleFocusMode: () => void + setActiveTag: (tag: string | null) => void + setSortBy: (by: 'updatedAt' | 'createdAt' | 'title') => void + setSortOrder: (order: 'asc' | 'desc') => void filteredNotes: () => Note[] } @@ -39,6 +45,9 @@ export const useAppStore = create((set, get) => ({ searchQuery: '', theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light', focusMode: false, + activeTag: null, + sortBy: 'updatedAt', + sortOrder: 'desc', loadAll: async () => { const [notes, folders] = await Promise.all([ @@ -72,12 +81,14 @@ export const useAppStore = create((set, get) => ({ return id }, - updateNote: async (id, patch) => { + updateNote: async (id, patch, opts) => { + const silent = opts?.silent ?? false 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 => ({ 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), })) }, @@ -94,7 +105,7 @@ export const useAppStore = create((set, get) => ({ toggleStar: async (id) => { const note = get().notes.find(n => n.id === id) if (!note) return - await get().updateNote(id, { starred: !note.starred }) + await get().updateNote(id, { starred: !note.starred }, { silent: true }) }, createFolder: async (name, parentId = null) => { @@ -132,9 +143,12 @@ export const useAppStore = create((set, get) => ({ }, toggleFocusMode: () => set(s => ({ focusMode: !s.focusMode })), + setActiveTag: (tag) => set({ activeTag: tag }), + setSortBy: (by) => set({ sortBy: by }), + setSortOrder: (order) => set({ sortOrder: order }), filteredNotes: () => { - const { notes, activeFolderId, searchQuery } = get() + const { notes, activeFolderId, searchQuery, activeTag, sortBy, sortOrder } = get() let result = notes if (activeFolderId === 'starred') { @@ -143,14 +157,29 @@ export const useAppStore = create((set, get) => ({ result = result.filter(n => n.folderId === activeFolderId) } + if (activeTag) { + result = result.filter(n => n.tags.includes(activeTag)) + } + if (searchQuery.trim()) { const q = searchQuery.toLowerCase() result = result.filter(n => n.title.toLowerCase().includes(q) || - n.tags.some(t => t.toLowerCase().includes(q)) + 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 }, }))