From bf2b16c78c819c21a701e84f2b5a8e45a0618108 Mon Sep 17 00:00:00 2001 From: MikiVL Date: Sat, 2 May 2026 19:16:50 +0800 Subject: [PATCH] feat: add welcome view, fix duplicate seeding, add star/delete UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace editable intro note with a static read-only WelcomeView component - Add "使用指南" sidebar nav item always pointing to WelcomeView - Fix React StrictMode double-seed race condition via module-level Promise singleton - Add deduplicateDB() to clean up already-inserted duplicates on startup - Add star button in editor title bar and sidebar note hover row - Add visible delete button on note item hover in sidebar - Default activeNoteId to '__welcome__' sentinel; deleted notes return to WelcomeView Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 6 +- src/components/editor/Editor.tsx | 44 ++++--- src/components/editor/WelcomeView.tsx | 176 ++++++++++++++++++++++++++ src/components/sidebar/Sidebar.tsx | 113 ++++++++++++----- src/db/index.ts | 79 +++++++----- src/stores/appStore.ts | 10 +- 6 files changed, 344 insertions(+), 84 deletions(-) create mode 100644 src/components/editor/WelcomeView.tsx diff --git a/src/App.tsx b/src/App.tsx index 7f80d5a..75f021c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { Sidebar } from './components/sidebar/Sidebar' import { Editor } from './components/editor/Editor' import { useAppStore } from './stores/appStore' -import { seedIfEmpty } from './db' +import { seedIfEmpty, deduplicateDB } from './db' export default function App() { const { loadAll, theme } = useAppStore() @@ -12,7 +12,9 @@ export default function App() { }, [theme]) useEffect(() => { - seedIfEmpty().then(() => loadAll()) + seedIfEmpty() + .then(() => deduplicateDB()) + .then(() => loadAll()) }, []) return ( diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 10cd16e..5ed3902 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -8,6 +8,7 @@ import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { Table } from '@tiptap/extension-table' import TableRow from '@tiptap/extension-table-row' +import { WelcomeView } from './WelcomeView' import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' import CharacterCount from '@tiptap/extension-character-count' @@ -19,7 +20,7 @@ import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code, Highlighter, List, ListOrdered, Quote, Heading1, Heading2, Heading3, Minus, CheckSquare, Table as TableIcon, - Type, + Type, Star, } from 'lucide-react' import { useAppStore } from '../../stores/appStore' import { countWords } from '../../lib/utils' @@ -66,7 +67,7 @@ function SlashMenu({ items, selectedIndex, onSelect }: { } export function Editor() { - const { activeNoteId, notes, updateNote } = useAppStore() + const { activeNoteId, notes, updateNote, toggleStar } = useAppStore() const activeNote = notes.find(n => n.id === activeNoteId) const [title, setTitle] = useState(activeNote?.title ?? '') @@ -219,28 +220,35 @@ export function Editor() { }, 500) } - if (!activeNote) { - return ( -
-
-
✏️
-

选择一篇笔记,或创建新笔记

-
-
- ) + if (!activeNote || activeNoteId === '__welcome__') { + return } return (
{/* Title */}
- handleTitleChange(e.target.value)} - placeholder="无标题" - className="w-full bg-transparent outline-none text-3xl font-bold" - style={{ color: 'var(--text)', letterSpacing: '-0.02em' }} - /> +
+ handleTitleChange(e.target.value)} + placeholder="无标题" + className="flex-1 bg-transparent outline-none text-3xl font-bold min-w-0" + style={{ color: 'var(--text)', letterSpacing: '-0.02em' }} + /> + +
{/* Floating bubble menu rendered via portal */} diff --git a/src/components/editor/WelcomeView.tsx b/src/components/editor/WelcomeView.tsx new file mode 100644 index 0000000..f9c2975 --- /dev/null +++ b/src/components/editor/WelcomeView.tsx @@ -0,0 +1,176 @@ +import { + FileText, Folder, Slash, MousePointer, Save, + Search, Moon, Plus, Hash, +} from 'lucide-react' +import { useAppStore } from '../../stores/appStore' + +const FEATURES = [ + { + icon: , + title: '富文本编辑', + desc: '标题、列表、引用、代码块、表格、任务清单,应有尽有', + }, + { + icon: , + title: '斜杠命令', + desc: '输入 / 唤出命令菜单,用键盘快速插入任意内容块', + }, + { + icon: , + title: '浮动工具栏', + desc: '选中文字后浮现格式工具栏,点击即可加粗、高亮、转标题', + }, + { + icon: , + title: '自动保存', + desc: '停止输入 1 秒后自动写入本地 IndexedDB,无需手动保存', + }, + { + icon: , + title: '文件夹管理', + desc: '创建多级文件夹,右键可重命名或删除,拖拽随意整理', + }, + { + icon: , + title: '全文搜索', + desc: '侧边栏搜索框实时过滤笔记标题和标签', + }, + { + icon: , + title: '标签系统', + desc: '为笔记添加多个标签,跨文件夹快速定位相关内容', + }, + { + icon: , + title: '深色模式', + desc: '点击左上角图标一键切换亮色 / 暗色主题', + }, +] + +const SHORTCUTS = [ + { keys: ['⌘', 'B'], desc: '粗体' }, + { keys: ['⌘', 'I'], desc: '斜体' }, + { keys: ['⌘', 'U'], desc: '下划线' }, + { keys: ['⌘', 'Z'], desc: '撤销' }, + { keys: ['/'], desc: '命令菜单' }, +] + +export function WelcomeView() { + const { createNote } = useAppStore() + + return ( +
+
+ + {/* Hero */} +
+
+ ✏️ +
+

+ 欢迎使用笔记 +

+

+ 一款基于 Vite + React + TipTap 构建的现代笔记应用。 +
+ 点击左侧笔记开始阅读,或新建一篇属于你的笔记。 +

+ +
+ + {/* Divider */} +
+ + {/* Features */} +

+ 功能一览 +

+
+ {FEATURES.map(f => ( +
+
+ {f.icon} +
+
{f.title}
+
{f.desc}
+
+ ))} +
+ + {/* Shortcuts */} +

+ 常用快捷键 +

+
+ {SHORTCUTS.map((s, i) => ( +
0 ? '1px solid var(--border)' : 'none', + background: 'var(--bg-subtle)', + }} + > + {s.desc} +
+ {s.keys.map(k => ( + + {k} + + ))} +
+
+ ))} +
+ +
+
+ ) +} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index f108a04..24e0690 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -2,18 +2,18 @@ import { useState, useRef, useEffect } from 'react' import { Search, Plus, Star, FileText, Folder, FolderOpen, ChevronRight, ChevronDown, - Trash2, Edit2, Moon, Sun, FolderPlus, Hash, + Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen, } from 'lucide-react' import { useAppStore } from '../../stores/appStore' import { formatDate } from '../../lib/utils' -import type { Folder as FolderType } from '../../db' +import type { Folder as FolderType, Note } from '../../db' export function Sidebar() { const { notes, folders, activeNoteId, activeFolderId, searchQuery, theme, createNote, createFolder, deleteNote, deleteFolder, updateFolder, setActiveNote, setActiveFolder, setSearch, - toggleTheme, filteredNotes, + toggleTheme, toggleStar, filteredNotes, } = useAppStore() const [expandedFolders, setExpandedFolders] = useState>(new Set()) @@ -117,8 +117,9 @@ export function Sidebar() { {/* Nav shortcuts */} {/* Folders section */} @@ -190,38 +191,18 @@ export function Sidebar() {
)} {displayed.map(note => ( -
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 }) }} - > -
-
- {note.starred && } - - {note.title || '无标题笔记'} - -
- {formatDate(note.updatedAt)} -
- {note.tags.length > 0 && ( -
- {note.tags.slice(0, 3).map(tag => ( - - {tag} - - ))} -
- )} -
+ /> ))}
@@ -304,3 +285,73 @@ function CtxItem({ icon, label, danger, onClick }: { icon: React.ReactNode; labe ) } + +function NoteItem({ note, active, onClick, onDelete, onToggleStar, onContextMenu }: { + note: Note + active: boolean + onClick: () => void + onDelete: () => void + onToggleStar: () => void + onContextMenu: (e: React.MouseEvent) => void +}) { + const [hovered, setHovered] = useState(false) + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > +
+
+ {note.starred && } + + {note.title || '无标题笔记'} + +
+ {hovered ? ( +
+ + +
+ ) : ( + + {formatDate(note.updatedAt)} + + )} +
+ {note.tags.length > 0 && ( +
+ {note.tags.slice(0, 3).map(tag => ( + + {tag} + + ))} +
+ )} +
+ ) +} diff --git a/src/db/index.ts b/src/db/index.ts index ad448dc..02a41ba 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -43,7 +43,15 @@ class NotesDB extends Dexie { export const db = new NotesDB() -export async function seedIfEmpty() { +// Module-level singleton prevents double-seeding under React StrictMode +let _seedPromise: Promise | null = null + +export function seedIfEmpty(): Promise { + if (!_seedPromise) _seedPromise = _doSeed() + return _seedPromise +} + +async function _doSeed() { const count = await db.notes.count() if (count > 0) return @@ -57,34 +65,6 @@ export async function seedIfEmpty() { ]) await db.notes.bulkAdd([ - { - id: crypto.randomUUID(), - title: '欢迎使用笔记应用', - content: JSON.stringify({ - type: 'doc', - content: [ - { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: '欢迎使用 ✨' }] }, - { type: 'paragraph', content: [{ type: 'text', text: '这是一款基于 Vite + React + TipTap 构建的现代笔记应用。' }] }, - { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '功能特性' }] }, - { type: 'bulletList', content: [ - { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '支持富文本编辑(标题、列表、代码块、引用等)' }] }] }, - { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '输入 / 呼出斜杠命令菜单' }] }] }, - { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '选中文字查看浮动工具栏' }] }] }, - { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '自动保存到 IndexedDB' }] }] }, - { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: '文件夹分类管理' }] }] }, - ]}, - { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: '代码示例' }] }, - { type: 'codeBlock', attrs: { language: 'typescript' }, content: [{ type: 'text', text: 'const hello = "Hello, World!"\nconsole.log(hello)' }] }, - { type: 'blockquote', content: [{ type: 'paragraph', content: [{ type: 'text', text: '好记性不如烂笔头。开始记录你的想法吧!' }] }] }, - ], - }), - folderId: null, - tags: ['入门'], - starred: true, - createdAt: now, - updatedAt: now, - wordCount: 80, - }, { id: crypto.randomUUID(), title: '项目计划', @@ -127,3 +107,44 @@ export async function seedIfEmpty() { }, ]) } + +// Removes duplicate folders and notes inserted by React StrictMode double-mount. +// Groups by (name + parentId) for folders, (title + createdAt bucket) for notes. +export async function deduplicateDB() { + // --- folders --- + const allFolders = await db.folders.toArray() + const folderSeen = new Map() // key → keep id + const folderRemap = new Map() // deleted id → kept id + + for (const f of allFolders) { + const key = `${f.parentId ?? ''}::${f.name}` + if (!folderSeen.has(key)) { + folderSeen.set(key, f.id) + } else { + folderRemap.set(f.id, folderSeen.get(key)!) + await db.folders.delete(f.id) + } + } + + // Re-point notes that referenced a deleted folder + if (folderRemap.size > 0) { + const notesToFix = await db.notes.filter(n => n.folderId !== null && folderRemap.has(n.folderId!)).toArray() + for (const n of notesToFix) { + await db.notes.update(n.id, { folderId: folderRemap.get(n.folderId!)! }) + } + } + + // --- notes: deduplicate by title + createdAt within 2 s window --- + const allNotes = await db.notes.toArray() + const noteSeen = new Map() // key → kept createdAt + + for (const n of allNotes) { + const bucket = Math.floor(n.createdAt / 2000) + const key = `${n.title}::${bucket}` + if (!noteSeen.has(key)) { + noteSeen.set(key, n.createdAt) + } else { + await db.notes.delete(n.id) + } + } +} diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 7fc1a4b..0eae1c8 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -32,7 +32,7 @@ interface AppState { export const useAppStore = create((set, get) => ({ notes: [], folders: [], - activeNoteId: null, + activeNoteId: '__welcome__' as string | null, activeFolderId: 'all', searchQuery: '', theme: (localStorage.getItem('theme') as 'light' | 'dark') || 'light', @@ -43,8 +43,10 @@ export const useAppStore = create((set, get) => ({ db.folders.orderBy('order').toArray(), ]) set({ notes, folders }) - if (notes.length > 0 && !get().activeNoteId) { - set({ activeNoteId: notes[0].id }) + // Keep welcome screen as default; only auto-select if already on a real note + const cur = get().activeNoteId + if (cur && cur !== '__welcome__' && !notes.find(n => n.id === cur)) { + set({ activeNoteId: '__welcome__' }) } }, @@ -81,7 +83,7 @@ export const useAppStore = create((set, get) => ({ await db.notes.delete(id) set(s => { const notes = s.notes.filter(n => n.id !== id) - const activeNoteId = s.activeNoteId === id ? (notes[0]?.id ?? null) : s.activeNoteId + const activeNoteId = s.activeNoteId === id ? '__welcome__' : s.activeNoteId return { notes, activeNoteId } }) },