Skip to content

Commit 40d7366

Browse files
committed
feat(notes): 搜索栏重构为笔记内筛选+批量删除+模板筛选+文件夹树删除
1 parent cf76a18 commit 40d7366

12 files changed

Lines changed: 627 additions & 247 deletions

File tree

‎client/src/features/notes/components/CornellLayout.tsx‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,8 @@ function useCornellEditor(
5454
// eslint-disable-next-line react-hooks/exhaustive-deps
5555
}, [editor, JSON.stringify(initialContent)]);
5656

57-
// 确保编辑器在组件卸载时正确销毁
58-
useEffect(() => {
59-
return () => {
60-
if (editor) {
61-
editor.destroy();
62-
}
63-
};
64-
}, [editor]);
57+
// 编辑器生命周期由 @tiptap/react v3 useEditor 托管(scheduleDestroy 延迟销毁
58+
// 机制抵御 React 18 StrictMode 双调用,卸载时自动销毁),无需自定义 destroy。
6559

6660
return editor;
6761
}

‎client/src/features/notes/components/FreeCanvas.tsx‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -283,12 +283,15 @@ export default function FreeCanvas({ content, onChange }: FreeCanvasProps) {
283283
return;
284284
}
285285

286-
// ---- 普通左键:清除选中 ----
286+
// ---- 普通左键:清除选中(仅空白区域;块内保留编辑器焦点,否则点击文本块无法输入) ----
287287
if (e.button === 0 && !e.shiftKey) {
288-
setSelectedBlockIds(new Set());
289-
(document.activeElement as HTMLElement)?.blur?.();
290-
suppressSelectRef.current = true;
291-
setTimeout(() => { suppressSelectRef.current = false; }, 100);
288+
const insideBlock = !!(e.target as HTMLElement).closest?.('[data-freeblock]');
289+
if (!insideBlock) {
290+
setSelectedBlockIds(new Set());
291+
(document.activeElement as HTMLElement)?.blur?.();
292+
suppressSelectRef.current = true;
293+
setTimeout(() => { suppressSelectRef.current = false; }, 100);
294+
}
292295
}
293296
};
294297

‎client/src/features/notes/components/FreeTextBlock.tsx‎

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* @ai-context: 通用组件:FreeTextBlock。
33
*/
4-
import { useRef, useCallback, useEffect, useState } from 'react';
4+
import { useRef, useCallback, useState } from 'react';
55
import { useEditor, EditorContent } from '@tiptap/react';
66
import StarterKit from '@tiptap/starter-kit';
77
import { GripVertical, Trash2, Copy, Pencil } from 'lucide-react';
@@ -168,13 +168,7 @@ export default function FreeTextBlock({
168168
setBlockContextMenu(null);
169169
}, [onDuplicate, block.id]);
170170

171-
// 清理编辑器
172-
useEffect(() => {
173-
return () => {
174-
editor?.destroy();
175-
};
176-
// eslint-disable-next-line react-hooks/exhaustive-deps
177-
}, []);
171+
// 编辑器生命周期由 @tiptap/react v3 useEditor 托管(卸载时自动销毁),无需自定义 destroy。
178172

179173
const blockHeight = typeof block.size.height === 'number' ? block.size.height : undefined;
180174

@@ -194,7 +188,11 @@ export default function FreeTextBlock({
194188
'bg-bg-elevated rounded-kb-md shadow-sm overflow-hidden flex flex-col border',
195189
isSelected ? 'ring-2 ring-blue-500/40 border-blue-500' : 'border-border/50',
196190
)}
197-
onClick={(e) => onSelect(block.id, e.shiftKey)}
191+
onClick={(e) => {
192+
onSelect(block.id, e.shiftKey);
193+
// 点击块体即聚焦编辑器,避免首次点击无响应(块内 mousedown 不再被画布 blur)
194+
if (!isDraggingRef.current) editor?.commands.focus();
195+
}}
198196
onDoubleClick={(e) => e.stopPropagation()}
199197
onContextMenu={handleContextMenu}
200198
>
Lines changed: 51 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -1,211 +1,102 @@
11
/**
2-
* @ai-context: 通用组件:NoteSearchBar。
2+
* 笔记搜索栏
3+
*
4+
* @ai-context: 内测反馈重构:全局搜索(跨模块搜内容)已独立至 Ctrl+K 命令面板,
5+
* 笔记页搜索框收窄为「笔记内部筛选」——关键词即时过滤笔记列表(本地内存过滤,
6+
* getFilteredNotes 同步生效)+ 模板筛选 tabs(与卡片模板 Tag 共用 selectedTemplate
7+
* 状态,双向联动,再点取消,可逆)。
38
*/
4-
import { useState, useEffect, useRef, useCallback } from 'react';
5-
import { motion, AnimatePresence } from 'framer-motion';
6-
import { Input } from '@/components/ui';
7-
import { Search, X, Loader2, FileText, Layers, Brain, Lightbulb, GraduationCap } from 'lucide-react';
9+
import { useState } from 'react';
10+
import { Search, X } from 'lucide-react';
811
import { cn } from '@/lib/utils';
912
import { soundPlayer } from '@/lib/audio/SoundPlayer';
1013
import { useNoteStore } from '../store/useNoteStore';
1114
import { useShallow } from 'zustand/react/shallow';
12-
import { useReducedMotion } from '@/hooks/useReducedMotion';
13-
import type { SearchEntityType } from '@/types/models';
14-
15-
/**
16-
* 笔记搜索栏组件
17-
* v0.9.0: 带 300ms 防抖的搜索输入框 + 结果计数 + 关键词高亮辅助
18-
* v1.2.0: 全局统一搜索,支持实体类型筛选标签 + 结果类型图标
19-
*/
20-
21-
/** 实体类型筛选配置 */
22-
const ENTITY_TYPE_TABS: Array<{
23-
type: SearchEntityType | 'all';
24-
label: string;
25-
icon: React.ReactNode;
26-
}> = [
27-
{ type: 'all', label: '全部', icon: <Search className="w-3 h-3" /> },
28-
{ type: 'note', label: '笔记', icon: <FileText className="w-3 h-3" /> },
29-
{ type: 'flashcard', label: '闪卡', icon: <Layers className="w-3 h-3" /> },
30-
{ type: 'feynman', label: '费曼', icon: <Brain className="w-3 h-3" /> },
31-
{ type: 'inspiration', label: '灵感', icon: <Lightbulb className="w-3 h-3" /> },
32-
{ type: 'classroom', label: '课堂', icon: <GraduationCap className="w-3 h-3" /> },
15+
import { Input } from '@/components/ui/Input';
16+
import type { NoteTemplate } from '../components/TemplateSelector';
17+
18+
/** 模板筛选 tabs(与卡片模板 Tag 共用 selectedTemplate 状态) */
19+
const TEMPLATE_TABS: Array<{ type: NoteTemplate | 'all'; label: string }> = [
20+
{ type: 'all', label: '全部' },
21+
{ type: 'outline', label: '大纲式' },
22+
{ type: 'cornell', label: '康奈尔' },
23+
{ type: 'mindmap', label: '思维导图' },
24+
{ type: 'free', label: '自由笔记' },
25+
{ type: 'blank', label: '空白' },
26+
{ type: 'todo', label: '待办' },
3327
];
3428

35-
/** 搜索结果类型图标 */
36-
export function EntityTypeIcon({ type, className }: { type: SearchEntityType; className?: string }) {
37-
const iconMap: Record<SearchEntityType, React.ReactNode> = {
38-
note: <FileText className={cn('w-3.5 h-3.5', className)} />,
39-
flashcard: <Layers className={cn('w-3.5 h-3.5', className)} />,
40-
feynman: <Brain className={cn('w-3.5 h-3.5', className)} />,
41-
inspiration: <Lightbulb className={cn('w-3.5 h-3.5', className)} />,
42-
classroom: <GraduationCap className={cn('w-3.5 h-3.5', className)} />,
43-
};
44-
return <>{iconMap[type]}</>;
45-
}
46-
47-
/** 实体类型对应的颜色样式 */
48-
const ENTITY_TYPE_COLORS: Record<SearchEntityType, string> = {
49-
note: 'bg-brand-500/10 text-brand-600 border-brand-300/30',
50-
flashcard: 'bg-emerald-500/10 text-emerald-600 border-emerald-300/30',
51-
feynman: 'bg-violet-500/10 text-violet-600 border-violet-300/30',
52-
inspiration: 'bg-amber-500/10 text-amber-600 border-amber-300/30',
53-
classroom: 'bg-sky-500/10 text-sky-600 border-sky-300/30',
54-
};
55-
56-
/** 实体类型的中文标签 */
57-
export const ENTITY_TYPE_LABELS: Record<SearchEntityType, string> = {
58-
note: '笔记',
59-
flashcard: '闪卡',
60-
feynman: '费曼',
61-
inspiration: '灵感',
62-
classroom: '课堂',
63-
};
64-
6529
export function NoteSearchBar() {
6630
const {
6731
searchQuery,
68-
searchResults,
69-
searchNotes,
7032
setSearchQuery,
71-
selectedEntityTypes,
72-
setSelectedEntityTypes,
73-
} = useNoteStore(useShallow(s => ({
33+
selectedTemplate,
34+
toggleTemplate,
35+
} = useNoteStore(useShallow((s) => ({
7436
searchQuery: s.searchQuery,
75-
searchResults: s.searchResults,
76-
searchNotes: s.searchNotes,
7737
setSearchQuery: s.setSearchQuery,
78-
selectedEntityTypes: s.selectedEntityTypes,
79-
setSelectedEntityTypes: s.setSelectedEntityTypes,
38+
selectedTemplate: s.selectedTemplate,
39+
toggleTemplate: s.toggleTemplate,
8040
})));
81-
const prefersReduced = useReducedMotion();
8241
const [localQuery, setLocalQuery] = useState(searchQuery);
83-
const [isSearching, setIsSearching] = useState(false);
84-
const timerRef = useRef<ReturnType<typeof setTimeout>>();
85-
const queryRef = useRef(localQuery);
86-
87-
// 保持 queryRef 最新
88-
queryRef.current = localQuery;
89-
90-
// 防抖搜索:300ms
91-
const debouncedSearch = useCallback(
92-
(query: string, entityTypes?: SearchEntityType[]) => {
93-
if (timerRef.current) clearTimeout(timerRef.current);
94-
if (!query.trim()) {
95-
setSearchQuery('');
96-
setIsSearching(false);
97-
return;
98-
}
99-
setIsSearching(true);
100-
timerRef.current = setTimeout(async () => {
101-
await searchNotes(query, { limit: 50, fuzzy: true, entityTypes });
102-
setIsSearching(false);
103-
}, 300);
104-
},
105-
[searchNotes, setSearchQuery],
106-
);
107-
108-
useEffect(() => {
109-
return () => {
110-
if (timerRef.current) clearTimeout(timerRef.current);
111-
};
112-
}, []);
11342

11443
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
11544
const val = e.target.value;
11645
setLocalQuery(val);
117-
debouncedSearch(val, selectedEntityTypes);
46+
// 本地即时过滤:getFilteredNotes 按 searchQuery 过滤笔记列表(无索引延迟)
47+
setSearchQuery(val);
11848
};
11949

12050
const handleClear = () => {
12151
setLocalQuery('');
12252
setSearchQuery('');
123-
setIsSearching(false);
124-
if (timerRef.current) clearTimeout(timerRef.current);
12553
};
12654

127-
const handleTypeFilter = (type: SearchEntityType | 'all') => {
128-
const currentType = selectedEntityTypes.length === 0 ? 'all' : selectedEntityTypes[0];
129-
if (type !== currentType) soundPlayer.play('ui_tab_switch');
130-
const newTypes = type === 'all' ? [] : [type];
131-
setSelectedEntityTypes(newTypes);
132-
// 如果有搜索词,立即用新过滤条件搜索
133-
if (queryRef.current.trim()) {
134-
debouncedSearch(queryRef.current, newTypes);
55+
const handleTemplateFilter = (type: NoteTemplate | 'all') => {
56+
// 「全部」点击清除筛选;模板点击切换(再点取消,可逆)
57+
if (type === 'all') {
58+
if (selectedTemplate) {
59+
soundPlayer.play('ui_tab_switch');
60+
toggleTemplate(selectedTemplate);
61+
}
62+
} else {
63+
if (selectedTemplate !== type) soundPlayer.play('ui_tab_switch');
64+
toggleTemplate(type);
13565
}
13666
};
13767

138-
const resultCount = searchResults.length;
139-
const showResultBadge = searchQuery.trim().length > 0 && !isSearching;
140-
const activeType = selectedEntityTypes.length === 0 ? 'all' : selectedEntityTypes[0];
141-
14268
return (
14369
<div className="flex flex-col gap-2 flex-1 min-w-0">
14470
<div className="flex items-center gap-2 flex-1 min-w-0">
14571
<Input
146-
placeholder="搜索笔记、闪卡、费曼、灵感、课堂笔记..."
72+
placeholder="搜索笔记…"
14773
prefix={<Search className="w-4 h-4" strokeWidth={1.5} />}
14874
suffix={
149-
<AnimatePresence>
150-
{isSearching ? (
151-
<motion.div
152-
key="loading"
153-
initial={{ opacity: 0, scale: 0.8 }}
154-
animate={{ opacity: 1, scale: 1 }}
155-
exit={{ opacity: 0, scale: 0.8 }}
156-
transition={prefersReduced ? { duration: 0.01 } : undefined}
157-
>
158-
<Loader2 className="w-4 h-4 animate-spin text-text-tertiary" strokeWidth={1.5} />
159-
</motion.div>
160-
) : localQuery ? (
161-
<motion.button
162-
key="clear"
163-
initial={{ opacity: 0, scale: 0.8 }}
164-
animate={{ opacity: 1, scale: 1 }}
165-
exit={{ opacity: 0, scale: 0.8 }}
166-
transition={prefersReduced ? { duration: 0.01 } : { type: 'spring', stiffness: 400, damping: 25 }}
167-
whileTap={{ scale: 0.9 }}
168-
onClick={handleClear}
169-
className="p-0.5 rounded-full text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-200"
170-
>
171-
<X className="w-3.5 h-3.5" strokeWidth={1.5} />
172-
</motion.button>
173-
) : null}
174-
</AnimatePresence>
75+
localQuery ? (
76+
<button
77+
onClick={handleClear}
78+
className="p-0.5 rounded-full text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-200"
79+
aria-label="清除搜索"
80+
>
81+
<X className="w-3.5 h-3.5" strokeWidth={1.5} />
82+
</button>
83+
) : null
17584
}
17685
size="sm"
17786
className="flex-1 min-w-0"
17887
value={localQuery}
17988
onChange={handleChange}
18089
/>
181-
<AnimatePresence>
182-
{showResultBadge && (
183-
<motion.span
184-
initial={prefersReduced ? false : { opacity: 0, scale: 0.8, x: -8 }}
185-
animate={{ opacity: 1, scale: 1, x: 0 }}
186-
exit={{ opacity: 0, scale: 0.8, x: -8 }}
187-
transition={prefersReduced ? { duration: 0.01 } : { type: 'spring', stiffness: 400, damping: 28 }}
188-
className={cn(
189-
'flex-shrink-0 px-2 py-0.5 rounded-full text-[11px] font-medium',
190-
resultCount > 0
191-
? 'bg-brand-500/10 text-brand-600 border border-brand-300/30'
192-
: 'bg-bg-tertiary/40 text-text-tertiary border border-border/30',
193-
)}
194-
>
195-
{resultCount > 0 ? `${resultCount} 条结果` : '无结果'}
196-
</motion.span>
197-
)}
198-
</AnimatePresence>
19990
</div>
20091

201-
{/* 实体类型筛选标签 */}
92+
{/* 模板筛选 tabs:笔记内部维度,与卡片模板 Tag 联动 */}
20293
<div className="flex items-center gap-1 flex-wrap">
203-
{ENTITY_TYPE_TABS.map((tab) => {
204-
const isActive = activeType === tab.type;
94+
{TEMPLATE_TABS.map((tab) => {
95+
const isActive = tab.type === 'all' ? !selectedTemplate : selectedTemplate === tab.type;
20596
return (
20697
<button
20798
key={tab.type}
208-
onClick={() => handleTypeFilter(tab.type)}
99+
onClick={() => handleTemplateFilter(tab.type)}
209100
className={cn(
210101
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium',
211102
'border transition-all duration-200',
@@ -214,7 +105,6 @@ export function NoteSearchBar() {
214105
: 'bg-bg-tertiary/20 text-text-tertiary border-border/20 hover:bg-bg-tertiary/40 hover:text-text-secondary',
215106
)}
216107
>
217-
{tab.icon}
218108
{tab.label}
219109
</button>
220110
);
@@ -223,20 +113,3 @@ export function NoteSearchBar() {
223113
</div>
224114
);
225115
}
226-
227-
/**
228-
* 搜索结果类型徽章(可在搜索结果列表中使用)
229-
*/
230-
export function EntityTypeBadge({ type }: { type: SearchEntityType }) {
231-
return (
232-
<span
233-
className={cn(
234-
'inline-flex items-center gap-0.5 px-1.5 py-0 rounded-full text-[10px] font-medium border',
235-
ENTITY_TYPE_COLORS[type],
236-
)}
237-
>
238-
<EntityTypeIcon type={type} className="w-2.5 h-2.5" />
239-
{ENTITY_TYPE_LABELS[type]}
240-
</span>
241-
);
242-
}

0 commit comments

Comments
 (0)