Skip to content

Commit 8420d3b

Browse files
committed
refactor(ui): 拆 NoteListView 第 4 步 — 多选与菜单态 hook
1 parent 3fee45e commit 8420d3b

3 files changed

Lines changed: 146 additions & 90 deletions

File tree

‎app/src/components/NoteListView.tsx‎

Lines changed: 11 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,18 @@
1313
* @ai-context: 树序范围:全局可见序(树=未分组+各组顺次、折叠组行不参与)为
1414
* 区间/划选唯一基准;跨组语义=归组(目标手排时按落点插入)。
1515
*/
16-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16+
import { useCallback, useEffect, useMemo, useState } from "react";
1717
import type { Note, NoteGroup } from "../types";
1818
import { paletteHex } from "../utils/colorPalette";
1919
import type { ThemeMode } from "../utils/colorPalette";
20-
import { emptySelection, rangeSelection, toggleSelection } from "../utils/noteSelection";
2120
// 批 0-C2:展示节/可见序纯函数层(含 scope 键与裸折叠键派生——纯逻辑与副作用分离)
2221
import { buildSections, buildVisibleOrder, isTreeMode, manualBaseIds, scopeKey } from "../utils/noteSectionModel";
2322
// 批 0-C2:序行 store(笔记手排序行 + 组手排序行)——兑现既有登记拆分计划
2423
import { useNoteOrders } from "../hooks/useNoteOrders";
2524
// 批 0-C2:拖拽/移动三入口接线(共享一把行落点并发锁——硬性同文件约束)
2625
import { useNoteMoves } from "../hooks/useNoteMoves";
26+
// 批 0-C2:多选三通道 + 菜单三态 + 唯一 Esc handler(可见序镜像同文件)
27+
import { useNoteListSelection } from "../hooks/useNoteListSelection";
2728
import NoteListRow from "./NoteListRow";
2829
import NoteTreeSection from "./NoteTreeSection";
2930
import NoteRowContextMenu from "./NoteRowContextMenu";
@@ -88,53 +89,12 @@ export default function NoteListView({
8889
[],
8990
);
9091

91-
// ── 多选态(REQ-287):selectionMode=批量选择模式(单击=勾选);anchor=区间锚
92-
const [selection, setSelection] = useState<Set<number>>(emptySelection());
93-
const [anchor, setAnchor] = useState<number | null>(null);
94-
const [selectionMode, setSelectionMode] = useState(false);
95-
// 右键/批处理面板
96-
const [contextMenu, setContextMenu] = useState<{ note: Note; x: number; y: number } | null>(null);
97-
const [batchMenu, setBatchMenu] = useState<{ ids: number[]; x: number; y: number } | null>(null);
98-
const [batchMoveOpen, setBatchMoveOpen] = useState(false);
99-
10092
// 组折叠态
10193
const [groupFolds, setGroupFolds] = useState<Record<string, boolean>>({});
10294

10395
// 序行 store(笔记手排序行 + 组手排序行)——装载/保存路径下沉 hooks/useNoteOrders
10496
const { manualOrders, groupOrderRows, saveOrder, resetOrder } = useNoteOrders(refreshToken);
10597

106-
const visibleIdsRef = useRef<number[]>([]);
107-
108-
const clearSelection = useCallback(() => {
109-
setSelection(emptySelection());
110-
setAnchor(null);
111-
}, []);
112-
const exitBatch = useCallback(() => { setSelectionMode(false); clearSelection(); }, [clearSelection]);
113-
114-
// 列表数据变化裁剪(只留可见子集——既有安全边界)
115-
useEffect(() => {
116-
setSelection((cur) => {
117-
if (cur.size === 0) return cur;
118-
const visible = new Set(notes.map((n) => n.id));
119-
let changed = false;
120-
const next = new Set<number>();
121-
for (const id of cur) if (visible.has(id)) next.add(id); else changed = true;
122-
return changed ? next : cur;
123-
});
124-
}, [notes]);
125-
126-
// Esc:先退批处理面板 → 批量模式(清多选退出)
127-
useEffect(() => {
128-
const onKey = (e: KeyboardEvent) => {
129-
if (e.key !== "Escape") return;
130-
if (batchMenu) { setBatchMenu(null); setBatchMoveOpen(false); return; }
131-
if (contextMenu) { setContextMenu(null); return; }
132-
if (selectionMode || selection.size > 0) exitBatch();
133-
};
134-
window.addEventListener("keydown", onKey);
135-
return () => window.removeEventListener("keydown", onKey);
136-
}, [batchMenu, contextMenu, selectionMode, selection.size, exitBatch]);
137-
13898
// 手动序装载(REQ-287:notes 行)+ REQ-315:组序行(树组头排序)——见 hooks/useNoteOrders
13999
// (依赖 refreshToken 的理由随实现搬至该 hook 头注释:审查 P2-10 的令牌重拉)
140100

@@ -171,43 +131,19 @@ export default function NoteListView({
171131

172132
// 可见序(L1 审查:折叠组行不参与区间/划选——与渲染可见一致;折叠组头仍在)
173133
const visibleOrder = useMemo(() => buildVisibleOrder(sections, groupFolds), [sections, groupFolds]);
174-
useEffect(() => { visibleIdsRef.current = visibleOrder; }, [visibleOrder]);
175134

176-
// ── 行交互 ──
177-
const handleOpen = useCallback((note: Note) => {
178-
if (selectionMode) {
179-
// 批量模式:单击=勾选(不换右栏)
180-
setSelection((cur) => toggleSelection(cur, note.id));
181-
setAnchor(note.id);
182-
return;
183-
}
184-
// 审查 L4:普通单击=单选并打开——先清既有选集,anchor 恒指向本次点击
185-
if (selection.size > 0) clearSelection();
186-
onSelect(note);
187-
setAnchor(note.id);
188-
}, [selectionMode, onSelect, selection.size, clearSelection]);
189-
190-
const handleModifierClick = useCallback((note: Note, ctrl: boolean, shift: boolean) => {
191-
if (ctrl) {
192-
setSelection((cur) => toggleSelection(cur, note.id));
193-
// 审查 L4:Ctrl 后 anchor 指向本次点击行(即使该行被移除——Explorer 同款)
194-
setAnchor(note.id);
195-
} else if (shift) {
196-
if (anchor == null) {
197-
// 审查 L4:无锚的首次 Shift=单选该行并设为锚(连按两次不再各加单行)
198-
setSelection(new Set([note.id]));
199-
setAnchor(note.id);
200-
} else {
201-
setSelection((cur) => rangeSelection(cur, visibleIdsRef.current, anchor, note.id));
202-
}
203-
}
204-
}, [anchor]);
135+
// ── 行交互 / 多选 / 菜单(Esc 优先级链、可见序镜像、选集裁剪)——见 hooks/useNoteListSelection
136+
const {
137+
selection, setSelection, selectionMode, setSelectionMode,
138+
contextMenu, setContextMenu, batchMenu, setBatchMenu, batchMoveOpen, setBatchMoveOpen,
139+
clearSelection, exitBatch, handleOpen, handleModifierClick, openRowContextMenu,
140+
closeBatchMenu, batchDelete,
141+
} = useNoteListSelection({ notes, visibleOrder, onSelect, onBatchDelete });
205142

206143
/** scope 手动底序(REQ-315):可见展示序去掉置顶区(快照只写本子序列)——纯函数在 noteSectionModel */
207144
const manualBaseOf = useCallback((scope: string): number[] | null => manualBaseIds(sections, scope), [sections]);
208145

209146
// 拖拽/移动三入口(归组 / 组内上移下移 / 行间落点)——共享一把并发锁,见 hooks/useNoteMoves
210-
const closeBatchMenu = useCallback(() => setBatchMenu(null), []);
211147
const { busyMove, moveToGroup, moveWithinScope, handleDropOnRow } = useNoteMoves({
212148
notes, treeMode, manualOrders, manualBaseOf, saveOrder, onNoteMoved, onCleanNotice, closeBatchMenu,
213149
});
@@ -269,13 +205,7 @@ export default function NoteListView({
269205
dragIds={selection.size > 0 && selection.has(n.id) ? [...selection] : []}
270206
onDropOnRow={handleDropOnRow}
271207
onOpenSession={onOpenSession}
272-
onContextMenu={(e, note) => {
273-
if (selection.size > 0 && selection.has(note.id)) {
274-
setBatchMenu({ ids: [...selection], x: e.clientX, y: e.clientY });
275-
} else {
276-
setContextMenu({ note, x: e.clientX, y: e.clientY });
277-
}
278-
}}
208+
onContextMenu={openRowContextMenu}
279209
/>
280210
);
281211

@@ -299,13 +229,6 @@ export default function NoteListView({
299229
};
300230
}, [contextMenu, treeMode, manualBaseOf, moveWithinScope]);
301231

302-
// 批处理:删除(父层确认)与移动到组
303-
const batchDelete = async () => {
304-
if (!batchMenu) return;
305-
const ok = await onBatchDelete(batchMenu.ids);
306-
if (ok) { setBatchMenu(null); setBatchMoveOpen(false); clearSelection(); }
307-
};
308-
309232
return (
310233
<div style={{ width, flexShrink: 0, borderRight: "1px solid #e5e7eb", display: "flex", flexDirection: "column", minWidth: 0 }}>
311234
<div style={{ padding: "10px 14px", borderBottom: "1px solid #e5e7eb", fontWeight: 600, display: "flex", alignItems: "center", gap: 8 }}>
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* useNoteListSelection — 行多选态 + 菜单态 + 批量删除(批 0-C2·Task 2 自 NoteListView 抽出)。
3+
*
4+
* @ai-context: 交互矩阵(REQ-287)的多选三通道与两个菜单的**唯一状态宿主**:
5+
* Ctrl/⌘+单击=加/减选(不换右栏)、Shift+单击=按**可见序位置**区间选、
6+
* 工具栏「选择」批量模式下行单击=勾选。菜单三态:batchMenu(选集菜单)/
7+
* contextMenu(单行菜单)/batchMoveOpen(选集菜单的二级「移动到组」视图)。
8+
* @ai-context: Esc 是**唯一** window keydown handler,优先级链不可拆散:
9+
* batchMenu → contextMenu → 批量模式/非空选集。菜单态因此必须留在本 hook,
10+
* 展示件(NoteListBatchMenu)做成受控组件——否则会出现两个 window keydown
11+
* 同时判定的语义漂移(行为依赖 React 批处理时序)。
12+
* @ai-context: `visibleIdsRef` 是可见序镜像,供 Shift 区间读取——handleModifierClick 的
13+
* 依赖数组里没有 visibleOrder,直接读 props 会拿到陈旧闭包。
14+
* @ai-context: 边界——notes 变化时把选集裁剪到可见子集(既有安全边界,防已删笔记残留);
15+
* 批量删除由父层确认(onBatchDelete 返回是否成功),**成功才清选**,
16+
* 失败保留选集供重试。anchor 语义(审查 L4):普通单击恒重设锚、Ctrl 后锚
17+
* 指向本次点击行(即使该行已被移除,Explorer 同款)、无锚首次 Shift=单选该行。
18+
*/
19+
import { useCallback, useEffect, useRef, useState } from "react";
20+
import type { MouseEvent } from "react";
21+
import type { Note } from "../types";
22+
import { emptySelection, rangeSelection, toggleSelection } from "../utils/noteSelection";
23+
24+
export interface NoteListSelectionInput {
25+
notes: Note[];
26+
/** 全局可见序(区间选基准——useNoteSections 产出;折叠组行已排除) */
27+
visibleOrder: number[];
28+
/** 行单击=打开右栏(批量选择模式下由本 hook 改判为勾选) */
29+
onSelect: (note: Note) => void;
30+
/** 批量删除(父层确认对话框 + 删除 + 刷新) */
31+
onBatchDelete: (ids: number[]) => Promise<boolean>;
32+
}
33+
34+
export function useNoteListSelection({ notes, visibleOrder, onSelect, onBatchDelete }: NoteListSelectionInput) {
35+
// ── 多选态(REQ-287):selectionMode=批量选择模式(单击=勾选);anchor=区间锚
36+
const [selection, setSelection] = useState<Set<number>>(emptySelection());
37+
const [anchor, setAnchor] = useState<number | null>(null);
38+
const [selectionMode, setSelectionMode] = useState(false);
39+
// 右键/批处理面板
40+
const [contextMenu, setContextMenu] = useState<{ note: Note; x: number; y: number } | null>(null);
41+
const [batchMenu, setBatchMenu] = useState<{ ids: number[]; x: number; y: number } | null>(null);
42+
const [batchMoveOpen, setBatchMoveOpen] = useState(false);
43+
44+
const visibleIdsRef = useRef<number[]>([]);
45+
46+
const clearSelection = useCallback(() => {
47+
setSelection(emptySelection());
48+
setAnchor(null);
49+
}, []);
50+
const exitBatch = useCallback(() => { setSelectionMode(false); clearSelection(); }, [clearSelection]);
51+
52+
// 列表数据变化裁剪(只留可见子集——既有安全边界)
53+
useEffect(() => {
54+
setSelection((cur) => {
55+
if (cur.size === 0) return cur;
56+
const visible = new Set(notes.map((n) => n.id));
57+
let changed = false;
58+
const next = new Set<number>();
59+
for (const id of cur) if (visible.has(id)) next.add(id); else changed = true;
60+
return changed ? next : cur;
61+
});
62+
}, [notes]);
63+
64+
// Esc:先退批处理面板 → 批量模式(清多选退出)
65+
useEffect(() => {
66+
const onKey = (e: KeyboardEvent) => {
67+
if (e.key !== "Escape") return;
68+
if (batchMenu) { setBatchMenu(null); setBatchMoveOpen(false); return; }
69+
if (contextMenu) { setContextMenu(null); return; }
70+
if (selectionMode || selection.size > 0) exitBatch();
71+
};
72+
window.addEventListener("keydown", onKey);
73+
return () => window.removeEventListener("keydown", onKey);
74+
}, [batchMenu, contextMenu, selectionMode, selection.size, exitBatch]);
75+
76+
useEffect(() => { visibleIdsRef.current = visibleOrder; }, [visibleOrder]);
77+
78+
// ── 行交互 ──
79+
const handleOpen = useCallback((note: Note) => {
80+
if (selectionMode) {
81+
// 批量模式:单击=勾选(不换右栏)
82+
setSelection((cur) => toggleSelection(cur, note.id));
83+
setAnchor(note.id);
84+
return;
85+
}
86+
// 审查 L4:普通单击=单选并打开——先清既有选集,anchor 恒指向本次点击
87+
if (selection.size > 0) clearSelection();
88+
onSelect(note);
89+
setAnchor(note.id);
90+
}, [selectionMode, onSelect, selection.size, clearSelection]);
91+
92+
const handleModifierClick = useCallback((note: Note, ctrl: boolean, shift: boolean) => {
93+
if (ctrl) {
94+
setSelection((cur) => toggleSelection(cur, note.id));
95+
// 审查 L4:Ctrl 后 anchor 指向本次点击行(即使该行被移除——Explorer 同款)
96+
setAnchor(note.id);
97+
} else if (shift) {
98+
if (anchor == null) {
99+
// 审查 L4:无锚的首次 Shift=单选该行并设为锚(连按两次不再各加单行)
100+
setSelection(new Set([note.id]));
101+
setAnchor(note.id);
102+
} else {
103+
setSelection((cur) => rangeSelection(cur, visibleIdsRef.current, anchor, note.id));
104+
}
105+
}
106+
}, [anchor]);
107+
108+
/** 行右键:命中选集内 → 选集菜单(批量语义);否则单行菜单(v0.16.1 既有) */
109+
const openRowContextMenu = useCallback((e: MouseEvent, note: Note) => {
110+
if (selection.size > 0 && selection.has(note.id)) {
111+
setBatchMenu({ ids: [...selection], x: e.clientX, y: e.clientY });
112+
} else {
113+
setContextMenu({ note, x: e.clientX, y: e.clientY });
114+
}
115+
}, [selection]);
116+
117+
/** 收起选集菜单(归组成功与菜单自身共用;引用稳定,供 useNoteMoves 调用) */
118+
const closeBatchMenu = useCallback(() => setBatchMenu(null), []);
119+
120+
// 批处理:删除(父层确认)
121+
const batchDelete = async () => {
122+
if (!batchMenu) return;
123+
const ok = await onBatchDelete(batchMenu.ids);
124+
if (ok) { setBatchMenu(null); setBatchMoveOpen(false); clearSelection(); }
125+
};
126+
127+
return {
128+
selection, setSelection, anchor, selectionMode, setSelectionMode,
129+
contextMenu, setContextMenu, batchMenu, setBatchMenu, batchMoveOpen, setBatchMoveOpen,
130+
clearSelection, exitBatch, handleOpen, handleModifierClick, openRowContextMenu,
131+
closeBatchMenu, batchDelete,
132+
};
133+
}

0 commit comments

Comments
 (0)