Skip to content

Commit f012968

Browse files
committed
refactor(ui): 拆 SessionListPanel 第 2 步 — 选择态机 hook
1 parent 8eb9806 commit f012968

2 files changed

Lines changed: 113 additions & 62 deletions

File tree

‎app/src/components/SessionListPanel.tsx‎

Lines changed: 11 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { invoke } from "@tauri-apps/api/core";
2020
import type { CourseGroup, OcrBlockHit, SegmentHit, Session, SessionListItem } from "../types";
2121
import { fmtMs } from "../utils/fmt";
2222
import { isSessionConvertible } from "../utils/sessionEligibility";
23-
import { emptySelection, rangeSelection, toggleSelection } from "../utils/noteSelection";
23+
import { useSessionSelection } from "../hooks/useSessionSelection";
2424
import SessionListRow from "./SessionListRow";
2525
import type { SessionRenameRequest } from "./SessionListRow";
2626
import SessionRowContextMenu from "./SessionRowContextMenu";
@@ -82,10 +82,6 @@ export default function SessionListPanel({
8282
const [filterConverted, setFilterConverted] = useState<ConvertedFilter>("all");
8383
const [sortBy, setSortBy] = useState<SortBy>("time-desc");
8484
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
85-
// ── 多选态(批 4):selectionMode=选择模式(单击=勾选);anchor=区间锚 ──
86-
const [selected, setSelected] = useState<Set<number>>(emptySelection());
87-
const [anchor, setAnchor] = useState<number | null>(null);
88-
const [selectionMode, setSelectionMode] = useState(false);
8985
const [contextMenu, setContextMenu] = useState<{ item: SessionListItem; x: number; y: number } | null>(null);
9086
// 行内重命名请求(nonce:同一行连续两次「重命名」也能重启编辑态)
9187
const [renameReq, setRenameReq] = useState<SessionRenameRequest | null>(null);
@@ -97,9 +93,6 @@ export default function SessionListPanel({
9793
const [batchBusy, setBatchBusy] = useState<"convert" | "delete" | null>(null);
9894
const batchBusyRef = useRef(false);
9995

100-
const clearSelection = useCallback(() => { setSelected(emptySelection()); setAnchor(null); }, []);
101-
const exitBatch = useCallback(() => { setSelectionMode(false); clearSelection(); }, [clearSelection]);
102-
10396
/** REQ-079:段搜索(片段上下文 + 点击跳详情) */
10497
const searchSegments = async () => {
10598
const kw = searchKw.trim();
@@ -195,23 +188,17 @@ export default function SessionListPanel({
195188
return filtered.map((i) => i.session.id);
196189
}, [grouped, groupedView, filtered, collapsed]);
197190

198-
const visibleOrderRef = useRef<number[]>([]);
199-
useEffect(() => { visibleOrderRef.current = visibleOrder; }, [visibleOrder]);
200-
201-
// 列表数据变化裁剪:只留当前可见行(筛选/折叠/删除后不残留幽灵勾选)
202-
useEffect(() => {
203-
setSelected((cur) => {
204-
if (cur.size === 0) return cur;
205-
const visible = new Set(visibleOrder);
206-
let changed = false;
207-
const next = new Set<number>();
208-
for (const id of cur) if (visible.has(id)) next.add(id); else changed = true;
209-
return changed ? next : cur;
210-
});
211-
}, [visibleOrder]);
191+
// ── 多选/选择模式状态机(批 0-C2 拆至 hooks/useSessionSelection)──
192+
const {
193+
selected, selectionMode, clearSelection, exitBatch, enterSelectionMode,
194+
rowOpen, rowModifier, toggleAllVisible,
195+
} = useSessionSelection({ visibleOrder, onOpenDetail });
212196

213197
// Esc 退出链:右键菜单 → 选择模式/多选态(清选集退出)。
214198
// (行内重命名输入内已 stopPropagation 自吞 Esc——不在此列)
199+
// ★ 全面板唯一 window keydown:优先级=右键菜单先 return(菜单开着时按 Esc 只关菜单,
200+
// 选集保留)。**不得**把 Esc 挪进 useSessionSelection 另建监听——两个监听会让一次
201+
// Esc 同时关菜单 + 清选集(行为不等价)。
215202
useEffect(() => {
216203
const onKey = (e: KeyboardEvent) => {
217204
if (e.key !== "Escape") return;
@@ -254,37 +241,6 @@ export default function SessionListPanel({
254241
}
255242
};
256243

257-
// ── 行交互(批 4):单击语义按模式分派;修饰键不换右栏 ──
258-
const rowOpen = useCallback((item: SessionListItem) => {
259-
if (selectionMode) {
260-
// 选择模式:单击=勾选(不打开详情)
261-
setSelected((cur) => toggleSelection(cur, item.session.id));
262-
setAnchor(item.session.id);
263-
return;
264-
}
265-
// 普通单击=单选语义并打开——先清既有选集(防误以为仍处多选态),锚恒指向本次点击
266-
if (selected.size > 0) clearSelection();
267-
onOpenDetail(item.session.id);
268-
setAnchor(item.session.id);
269-
}, [selectionMode, selected.size, onOpenDetail, clearSelection]);
270-
271-
const rowModifier = useCallback((item: SessionListItem, ctrl: boolean, shift: boolean) => {
272-
const id = item.session.id;
273-
if (ctrl) {
274-
// Ctrl/⌘:加/减单行;锚指向本次点击行
275-
setSelected((cur) => toggleSelection(cur, id));
276-
setAnchor(id);
277-
} else if (shift) {
278-
if (anchor == null) {
279-
// 无锚的首次 Shift=单选该行并设为锚(连按两次不再各加单行)
280-
setSelected(new Set([id]));
281-
setAnchor(id);
282-
} else {
283-
setSelected((cur) => rangeSelection(cur, visibleOrderRef.current, anchor, id));
284-
}
285-
}
286-
}, [anchor]);
287-
288244
/** 行渲染(平铺/分组共用);行内编辑与多选视觉在 SessionListRow */
289245
const renderRow = (item: SessionListItem) => (
290246
<SessionListRow
@@ -349,7 +305,7 @@ export default function SessionListPanel({
349305
<button
350306
data-testid="session-select-mode-btn"
351307
style={selectModeBtn(selectionMode)}
352-
onClick={() => (selectionMode ? exitBatch() : setSelectionMode(true))}
308+
onClick={() => (selectionMode ? exitBatch() : enterSelectionMode())}
353309
title={selectionMode ? "退出选择模式(Esc)" : "进入选择模式:单击会话=勾选(Ctrl/Shift 多选)"}
354310
>
355311
选择
@@ -533,14 +489,7 @@ export default function SessionListPanel({
533489
if (el) el.indeterminate = selected.size > 0 && selected.size < visibleOrder.length;
534490
}}
535491
checked={selected.size === visibleOrder.length && visibleOrder.length > 0}
536-
onChange={() => {
537-
if (selected.size === visibleOrder.length && visibleOrder.length > 0) {
538-
clearSelection();
539-
} else {
540-
setSelected(new Set(visibleOrder));
541-
setAnchor(null); // 全选后无区间锚(下次 Shift 需新锚)
542-
}
543-
}}
492+
onChange={toggleAllVisible}
544493
style={{ cursor: "pointer", flexShrink: 0 }}
545494
title="全选当前可见的会话(折叠组行不含在内)"
546495
/>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* useSessionSelection — 会话列表多选/选择模式状态机(批 0-C2 自 SessionListPanel 拆出)。
3+
*
4+
* @ai-context: 批 4 交互矩阵的状态持有者——selectionMode(「选择」按钮进入,单击行
5+
* =勾选)、selected(多选集)、anchor(Shift 区间锚)。单击分派
6+
* (rowOpen)与修饰键分派(rowModifier)在此收敛,行组件只上报事件;
7+
* 复用 utils/noteSelection 纯函数(零笔记域耦合)。
8+
* @ai-context 副作用与边界:
9+
* ① visibleOrder **由调用方注入**(本 hook 不推导)——它是 Shift 区间、
10+
* 选集裁剪、全选三态的唯一基准(折叠组隐藏行不在内,与笔记树同语义);
11+
* ② visibleOrderRef 是「同 tick 可读」镜像:rowModifier 在事件回调里
12+
* 同步读它。**不得**改成闭包捕获 visibleOrder(事件发生时闭包可能是
13+
* 上一帧的序 ⇒ 区间端点错位),**不得**改成 state(多一帧延迟);
14+
* ③ 裁剪 effect 必须保留 `changed ? next : cur`——集合未变时返回**同一
15+
* Set 引用**,否则每次 visibleOrder 变化都会让全列表行重渲染(测试
16+
* 只断言计数,发现不了这个性能回归);
17+
* ④ Esc 退出链**不在本 hook**:面板保留唯一 window keydown 监听,维持
18+
* 「右键菜单优先 return」的优先级(第二个监听会让一次 Esc 同时关菜单
19+
* + 清选集 = 行为不等价)。
20+
*/
21+
import { useCallback, useEffect, useRef, useState } from "react";
22+
import type { SessionListItem } from "../types";
23+
import { emptySelection, rangeSelection, toggleSelection } from "../utils/noteSelection";
24+
25+
interface Options {
26+
/** 当前可见行序(面板派生后注入;区间/裁剪/全选三态共用同一基准) */
27+
visibleOrder: number[];
28+
/** 普通单击打开详情(选择模式下不触发——单击=勾选) */
29+
onOpenDetail: (id: number) => void;
30+
}
31+
32+
export function useSessionSelection({ visibleOrder, onOpenDetail }: Options) {
33+
// ── 多选态(批 4):selectionMode=选择模式(单击=勾选);anchor=区间锚 ──
34+
const [selected, setSelected] = useState<Set<number>>(emptySelection());
35+
const [anchor, setAnchor] = useState<number | null>(null);
36+
const [selectionMode, setSelectionMode] = useState(false);
37+
38+
const clearSelection = useCallback(() => { setSelected(emptySelection()); setAnchor(null); }, []);
39+
const exitBatch = useCallback(() => { setSelectionMode(false); clearSelection(); }, [clearSelection]);
40+
const enterSelectionMode = useCallback(() => setSelectionMode(true), []);
41+
42+
const visibleOrderRef = useRef<number[]>([]);
43+
useEffect(() => { visibleOrderRef.current = visibleOrder; }, [visibleOrder]);
44+
45+
// 列表数据变化裁剪:只留当前可见行(筛选/折叠/删除后不残留幽灵勾选)
46+
useEffect(() => {
47+
setSelected((cur) => {
48+
if (cur.size === 0) return cur;
49+
const visible = new Set(visibleOrder);
50+
let changed = false;
51+
const next = new Set<number>();
52+
for (const id of cur) if (visible.has(id)) next.add(id); else changed = true;
53+
return changed ? next : cur;
54+
});
55+
}, [visibleOrder]);
56+
57+
// ── 行交互(批 4):单击语义按模式分派;修饰键不换右栏 ──
58+
const rowOpen = useCallback((item: SessionListItem) => {
59+
if (selectionMode) {
60+
// 选择模式:单击=勾选(不打开详情)
61+
setSelected((cur) => toggleSelection(cur, item.session.id));
62+
setAnchor(item.session.id);
63+
return;
64+
}
65+
// 普通单击=单选语义并打开——先清既有选集(防误以为仍处多选态),锚恒指向本次点击
66+
if (selected.size > 0) clearSelection();
67+
onOpenDetail(item.session.id);
68+
setAnchor(item.session.id);
69+
}, [selectionMode, selected.size, onOpenDetail, clearSelection]);
70+
71+
const rowModifier = useCallback((item: SessionListItem, ctrl: boolean, shift: boolean) => {
72+
const id = item.session.id;
73+
if (ctrl) {
74+
// Ctrl/⌘:加/减单行;锚指向本次点击行
75+
setSelected((cur) => toggleSelection(cur, id));
76+
setAnchor(id);
77+
} else if (shift) {
78+
if (anchor == null) {
79+
// 无锚的首次 Shift=单选该行并设为锚(连按两次不再各加单行)
80+
setSelected(new Set([id]));
81+
setAnchor(id);
82+
} else {
83+
setSelected((cur) => rangeSelection(cur, visibleOrderRef.current, anchor, id));
84+
}
85+
}
86+
}, [anchor]);
87+
88+
/** 全选框三态(P3-1):口径=visibleOrder(折叠组隐藏行不在内);满选再点=清空 */
89+
const toggleAllVisible = useCallback(() => {
90+
if (selected.size === visibleOrder.length && visibleOrder.length > 0) {
91+
clearSelection();
92+
} else {
93+
setSelected(new Set(visibleOrder));
94+
setAnchor(null); // 全选后无区间锚(下次 Shift 需新锚)
95+
}
96+
}, [selected.size, visibleOrder, clearSelection]);
97+
98+
return {
99+
selected, selectionMode, clearSelection, exitBatch, enterSelectionMode,
100+
rowOpen, rowModifier, toggleAllVisible,
101+
};
102+
}

0 commit comments

Comments
 (0)