Skip to content

Commit 3fee45e

Browse files
committed
refactor(ui): 拆 NoteListView 第 3 步 — 拖拽移动接线 hook
1 parent 5357b30 commit 3fee45e

2 files changed

Lines changed: 180 additions & 121 deletions

File tree

‎app/src/components/NoteListView.tsx‎

Lines changed: 7 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,16 @@
1414
* 区间/划选唯一基准;跨组语义=归组(目标手排时按落点插入)。
1515
*/
1616
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
17-
import { invoke } from "@tauri-apps/api/core";
1817
import type { Note, NoteGroup } from "../types";
19-
// REQ-316(批 7):移组返回契约(空组自动清理留痕数据源)
20-
import type { MoveNoteResult } from "../types/notes";
2118
import { paletteHex } from "../utils/colorPalette";
2219
import type { ThemeMode } from "../utils/colorPalette";
2320
import { emptySelection, rangeSelection, toggleSelection } from "../utils/noteSelection";
24-
// REQ-315:scope 内 置顶→手排→自动 排序纯函数 + 显式移动(树视图 pin 生效)
25-
import { dropNotesIntoOrder, shiftNoteOrder } from "../utils/noteOrder";
2621
// 批 0-C2:展示节/可见序纯函数层(含 scope 键与裸折叠键派生——纯逻辑与副作用分离)
2722
import { buildSections, buildVisibleOrder, isTreeMode, manualBaseIds, scopeKey } from "../utils/noteSectionModel";
2823
// 批 0-C2:序行 store(笔记手排序行 + 组手排序行)——兑现既有登记拆分计划
2924
import { useNoteOrders } from "../hooks/useNoteOrders";
25+
// 批 0-C2:拖拽/移动三入口接线(共享一把行落点并发锁——硬性同文件约束)
26+
import { useNoteMoves } from "../hooks/useNoteMoves";
3027
import NoteListRow from "./NoteListRow";
3128
import NoteTreeSection from "./NoteTreeSection";
3229
import NoteRowContextMenu from "./NoteRowContextMenu";
@@ -99,7 +96,6 @@ export default function NoteListView({
9996
const [contextMenu, setContextMenu] = useState<{ note: Note; x: number; y: number } | null>(null);
10097
const [batchMenu, setBatchMenu] = useState<{ ids: number[]; x: number; y: number } | null>(null);
10198
const [batchMoveOpen, setBatchMoveOpen] = useState(false);
102-
const [busyMove, setBusyMove] = useState(false);
10399

104100
// 组折叠态
105101
const [groupFolds, setGroupFolds] = useState<Record<string, boolean>>({});
@@ -108,8 +104,6 @@ export default function NoteListView({
108104
const { manualOrders, groupOrderRows, saveOrder, resetOrder } = useNoteOrders(refreshToken);
109105

110106
const visibleIdsRef = useRef<number[]>([]);
111-
// L5:行落点并发锁(防陈旧快照互覆)
112-
const dropBusyRef = useRef(false);
113107

114108
const clearSelection = useCallback(() => {
115109
setSelection(emptySelection());
@@ -212,119 +206,11 @@ export default function NoteListView({
212206
/** scope 手动底序(REQ-315):可见展示序去掉置顶区(快照只写本子序列)——纯函数在 noteSectionModel */
213207
const manualBaseOf = useCallback((scope: string): number[] | null => manualBaseIds(sections, scope), [sections]);
214208

215-
/** 拖拽归组(组头/左侧组行复用单 id 兜底仍可用) */
216-
const moveToGroup = useCallback(async (ids: number[], groupId: number | null) => {
217-
setBusyMove(true);
218-
try {
219-
const groupNotes = new Set(
220-
notes.filter((n) => (groupId == null ? n.group_id == null : n.group_id === groupId)).map((n) => n.id),
221-
);
222-
const cleanedNames: string[] = [];
223-
for (const id of ids) {
224-
if (groupNotes.has(id)) continue;
225-
const r = await invoke<MoveNoteResult>("move_note_to_group", { noteId: id, groupId });
226-
cleanedNames.push(...r.autoCleanedGroups);
227-
}
228-
// 目标手排:新入组未置顶笔记追加末尾(跨组 drop 的"加入该组"语义;
229-
// 置顶成员由置顶区表达,不写手排行;重写快照顺带清存量置顶残行)
230-
const scope = scopeKey(groupId);
231-
if (manualOrders[scope]) {
232-
const pinned = new Set(notes.filter((n) => n.pin === 1).map((n) => n.id));
233-
const cur = manualOrders[scope].filter((id) => !ids.includes(id) && !pinned.has(id));
234-
await saveOrder(scope, [...cur, ...ids.filter((id) => !groupNotes.has(id) && !pinned.has(id))]);
235-
}
236-
onNoteMoved?.();
237-
// REQ-316(批 7):批量移走后源空组清理留痕(跨条聚合去重)
238-
if (cleanedNames.length > 0) onCleanNotice?.([...new Set(cleanedNames)]);
239-
setBatchMenu(null);
240-
} catch (e) {
241-
console.warn("[notes] 归组失败:", e);
242-
} finally {
243-
setBusyMove(false);
244-
}
245-
}, [notes, manualOrders, saveOrder, onNoteMoved, onCleanNotice]);
246-
247-
/**
248-
* REQ-315:右键「上移/下移」组内显式移动——补"必须拖一次才触发手排快照"的
249-
* 发现性缺口:自动排序 scope 的首次显式移动 = 以当前可见序快照转手排再移动
250-
* (与行落点拖拽同一保存路径 saveOrder——无双轨)。
251-
*/
252-
const moveWithinScope = useCallback(async (note: Note, dir: 1 | -1) => {
253-
if (dropBusyRef.current || !treeMode) return;
254-
const gid = note.group_id ?? null;
255-
const base = manualBaseOf(scopeKey(gid));
256-
const next = base ? shiftNoteOrder(base, note.id, dir) : null;
257-
if (!next) return;
258-
dropBusyRef.current = true;
259-
try {
260-
await saveOrder(scopeKey(gid), next);
261-
onNoteMoved?.();
262-
} catch (e) {
263-
console.warn("[notes] 组内移动失败:", e);
264-
} finally {
265-
dropBusyRef.current = false;
266-
}
267-
}, [treeMode, manualBaseOf, saveOrder, onNoteMoved]);
268-
269-
/** 行间落点(同 scope 手动排序;跨组归入目标组后按落点插入——L2 审查修正:
270-
* 先归组、后整表覆写;目标不可见/无 ord 尾部不再静默 no-op) */
271-
const handleDropOnRow = useCallback(async (ids: number[], targetId: number, before: boolean) => {
272-
// L3:平铺态(搜索/标签/非默认排序)禁排序拖拽——矩阵锁定规则
273-
if (!treeMode) return;
274-
if (dropBusyRef.current) return;
275-
const target = notes.find((n) => n.id === targetId);
276-
if (!target || ids.includes(targetId)) return;
277-
dropBusyRef.current = true;
278-
try {
279-
const targetGroup = target.group_id ?? null;
280-
const scope = scopeKey(targetGroup);
281-
const base = manualBaseOf(scope);
282-
if (!base) return;
283-
// 跨组 id:先归入目标组(await 顺序执行——同事务语义由命令层保证)
284-
const external = ids.filter((id) => {
285-
const n = notes.find((x) => x.id === id);
286-
return n && (n.group_id ?? null) !== targetGroup;
287-
});
288-
const cleanedNames: string[] = [];
289-
for (const id of external) {
290-
const r = await invoke<MoveNoteResult>("move_note_to_group", { noteId: id, groupId: target.group_id });
291-
cleanedNames.push(...r.autoCleanedGroups);
292-
}
293-
// REQ-316(批 7):跨组拖走使源组变空 → 清理留痕(零清理零变化)
294-
if (cleanedNames.length > 0) onCleanNotice?.([...new Set(cleanedNames)]);
295-
// 落位(REQ-315:只写置顶区外子序列;落点在置顶行上 = 置顶区下沿即手动区首位)
296-
const pinned = new Set(notes.filter((n) => n.pin === 1).map((n) => n.id));
297-
const moved = ids.filter((id) => !pinned.has(id));
298-
if (moved.length === 0) {
299-
// 仅置顶成员拖拽:置顶区按更新时间定序不可移动——归组已完成即返回,不制造快照
300-
onNoteMoved?.();
301-
return;
302-
}
303-
const anchor = target.pin === 1 ? { head: true as const } : { targetId, before };
304-
const next = dropNotesIntoOrder(base, moved, anchor);
305-
if (!next) {
306-
// 目标不可见(折叠/异常)——至少完成归组,不写序
307-
onNoteMoved?.();
308-
return;
309-
}
310-
// 与既有序一致(如仅拖置顶行)= 跳过保存——防无变化操作制造无谓快照
311-
const prevBase = (manualOrders[scope] ?? []).filter((id) => {
312-
const n = notes.find((x) => x.id === id);
313-
return !n || n.pin !== 1;
314-
});
315-
if (prevBase.length === next.length && prevBase.every((id, i) => next[i] === id)) {
316-
onNoteMoved?.();
317-
return;
318-
}
319-
await saveOrder(scope, next);
320-
onNoteMoved?.();
321-
} catch (e) {
322-
console.warn("[notes] 行落点排序失败(部分操作可能已提交):", e);
323-
onNoteMoved?.();
324-
} finally {
325-
dropBusyRef.current = false;
326-
}
327-
}, [treeMode, notes, manualOrders, manualBaseOf, saveOrder, onNoteMoved, onCleanNotice]);
209+
// 拖拽/移动三入口(归组 / 组内上移下移 / 行间落点)——共享一把并发锁,见 hooks/useNoteMoves
210+
const closeBatchMenu = useCallback(() => setBatchMenu(null), []);
211+
const { busyMove, moveToGroup, moveWithinScope, handleDropOnRow } = useNoteMoves({
212+
notes, treeMode, manualOrders, manualBaseOf, saveOrder, onNoteMoved, onCleanNotice, closeBatchMenu,
213+
});
328214

329215
/** 划选(组头空白起 → 组内首行至当前行带;走既有行命中的全局可见序)。
330216
* L9 审查修正:rAF 节流 + pointercancel/blur/松开(buttons=0)即清理——

‎app/src/hooks/useNoteMoves.ts‎

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* useNoteMoves — 笔记拖拽/移动三入口接线(批 0-C2·Task 2 自 NoteListView 抽出)。
3+
*
4+
* @ai-context: 三入口同文件是**硬约束**,不是组织偏好——`dropBusyRef`(行落点并发锁,
5+
* 防陈旧快照互覆)被「右键组内上移/下移」与「行间落点」共用,且两者都走
6+
* 同一条整表覆写路径 saveOrder→note_order_save。拆进两个文件会得到两把
7+
* 独立 ref(锁失去意义)与双轨保存。
8+
* @ai-context: 入口一 moveToGroup(组头/左侧组行 drop、批量移动到组):逐条
9+
* move_note_to_group,目标 scope 有手排时把新入组未置顶笔记追加末尾
10+
* (置顶成员由置顶区表达,不写手排行;重写快照顺带清存量置顶残行);
11+
* 源组变空 → autoCleanedGroups 去重上抛 onCleanNotice(REQ-316 留痕)。
12+
* @ai-context: 入口二 moveWithinScope(右键上移/下移):自动排序 scope 的首次显式移动
13+
* = 以当前可见序快照转手排再移动(与行落点同一保存路径,无 discoverability
14+
* 缺口、无双轨);dropBusyRef 占用期间整体短路。
15+
* @ai-context: 入口三 handleDropOnRow(行间落点):跨组先归入目标组 → 取目标 scope 底序
16+
* → 只写置顶区外子序列 → 与既有序一致时跳过保存(防无谓快照)。**5 个
17+
* `onNoteMoved?.()` 出口(仅置顶成员 / 目标不可见 / 序无变化 / 正常 / catch)
18+
* 缺一即父层不刷新、UI 静默陈旧。**
19+
* @ai-context: 边界——treeMode=false(搜索/标签/非默认排序平铺)整条链路短路:平铺是
20+
* 过滤结果非完整 scope,不做 scope 级重排。移动命令失败一律 console.warn
21+
* (部分操作可能已提交)+ onNoteMoved 兜底刷新。本批只搬家:4×notes.find
22+
* 与 O(n²) 倾向**不优化**(触碰时序语义)。
23+
*/
24+
import { useCallback, useRef, useState } from "react";
25+
import { invoke } from "@tauri-apps/api/core";
26+
import type { Note } from "../types";
27+
// REQ-316(批 7):移组返回契约(空组自动清理留痕数据源)
28+
import type { MoveNoteResult } from "../types/notes";
29+
// REQ-315:scope 内 置顶→手排→自动 排序纯函数 + 显式移动(树视图 pin 生效)
30+
import { dropNotesIntoOrder, shiftNoteOrder } from "../utils/noteOrder";
31+
import { scopeKey } from "../utils/noteSectionModel";
32+
33+
export interface NoteMovesInput {
34+
notes: Note[];
35+
/** 树模式判据(平铺态禁排序拖拽——交互矩阵锁定规则) */
36+
treeMode: boolean;
37+
/** scope → 手排有序 ids(useNoteOrders) */
38+
manualOrders: Record<string, number[]>;
39+
/** scope 手动底序(可见展示序去掉置顶区;调用方以 useCallback 保持引用稳定) */
40+
manualBaseOf: (scope: string) => number[] | null;
41+
/** 整表覆写保存路径(useNoteOrders.saveOrder——三个入口唯一保存路径) */
42+
saveOrder: (scope: string, ids: number[]) => Promise<void>;
43+
/** 移动/归组完成 → 父层重载数据 */
44+
onNoteMoved?: () => void;
45+
/** REQ-316(批 7):移组触发源空组自动清理 → 上抛组标题(父层 toast 留痕) */
46+
onCleanNotice?: (groupNames: string[]) => void;
47+
/** 归组成功后收起选集菜单(菜单态在 useNoteListSelection,须传稳定引用) */
48+
closeBatchMenu: () => void;
49+
}
50+
51+
export function useNoteMoves({
52+
notes, treeMode, manualOrders, manualBaseOf, saveOrder, onNoteMoved, onCleanNotice, closeBatchMenu,
53+
}: NoteMovesInput) {
54+
const [busyMove, setBusyMove] = useState(false);
55+
// L5:行落点并发锁(防陈旧快照互覆)——moveWithinScope 与 handleDropOnRow 共用
56+
const dropBusyRef = useRef(false);
57+
58+
/** 拖拽归组(组头/左侧组行复用单 id 兜底仍可用) */
59+
const moveToGroup = useCallback(async (ids: number[], groupId: number | null) => {
60+
setBusyMove(true);
61+
try {
62+
const groupNotes = new Set(
63+
notes.filter((n) => (groupId == null ? n.group_id == null : n.group_id === groupId)).map((n) => n.id),
64+
);
65+
const cleanedNames: string[] = [];
66+
for (const id of ids) {
67+
if (groupNotes.has(id)) continue;
68+
const r = await invoke<MoveNoteResult>("move_note_to_group", { noteId: id, groupId });
69+
cleanedNames.push(...r.autoCleanedGroups);
70+
}
71+
// 目标手排:新入组未置顶笔记追加末尾(跨组 drop 的"加入该组"语义;
72+
// 置顶成员由置顶区表达,不写手排行;重写快照顺带清存量置顶残行)
73+
const scope = scopeKey(groupId);
74+
if (manualOrders[scope]) {
75+
const pinned = new Set(notes.filter((n) => n.pin === 1).map((n) => n.id));
76+
const cur = manualOrders[scope].filter((id) => !ids.includes(id) && !pinned.has(id));
77+
await saveOrder(scope, [...cur, ...ids.filter((id) => !groupNotes.has(id) && !pinned.has(id))]);
78+
}
79+
onNoteMoved?.();
80+
// REQ-316(批 7):批量移走后源空组清理留痕(跨条聚合去重)
81+
if (cleanedNames.length > 0) onCleanNotice?.([...new Set(cleanedNames)]);
82+
closeBatchMenu();
83+
} catch (e) {
84+
console.warn("[notes] 归组失败:", e);
85+
} finally {
86+
setBusyMove(false);
87+
}
88+
}, [notes, manualOrders, saveOrder, onNoteMoved, onCleanNotice, closeBatchMenu]);
89+
90+
/**
91+
* REQ-315:右键「上移/下移」组内显式移动——补"必须拖一次才触发手排快照"的
92+
* 发现性缺口:自动排序 scope 的首次显式移动 = 以当前可见序快照转手排再移动
93+
* (与行落点拖拽同一保存路径 saveOrder——无双轨)。
94+
*/
95+
const moveWithinScope = useCallback(async (note: Note, dir: 1 | -1) => {
96+
if (dropBusyRef.current || !treeMode) return;
97+
const gid = note.group_id ?? null;
98+
const base = manualBaseOf(scopeKey(gid));
99+
const next = base ? shiftNoteOrder(base, note.id, dir) : null;
100+
if (!next) return;
101+
dropBusyRef.current = true;
102+
try {
103+
await saveOrder(scopeKey(gid), next);
104+
onNoteMoved?.();
105+
} catch (e) {
106+
console.warn("[notes] 组内移动失败:", e);
107+
} finally {
108+
dropBusyRef.current = false;
109+
}
110+
}, [treeMode, manualBaseOf, saveOrder, onNoteMoved]);
111+
112+
/** 行间落点(同 scope 手动排序;跨组归入目标组后按落点插入——L2 审查修正:
113+
* 先归组、后整表覆写;目标不可见/无 ord 尾部不再静默 no-op) */
114+
const handleDropOnRow = useCallback(async (ids: number[], targetId: number, before: boolean) => {
115+
// L3:平铺态(搜索/标签/非默认排序)禁排序拖拽——矩阵锁定规则
116+
if (!treeMode) return;
117+
if (dropBusyRef.current) return;
118+
const target = notes.find((n) => n.id === targetId);
119+
if (!target || ids.includes(targetId)) return;
120+
dropBusyRef.current = true;
121+
try {
122+
const targetGroup = target.group_id ?? null;
123+
const scope = scopeKey(targetGroup);
124+
const base = manualBaseOf(scope);
125+
if (!base) return;
126+
// 跨组 id:先归入目标组(await 顺序执行——同事务语义由命令层保证)
127+
const external = ids.filter((id) => {
128+
const n = notes.find((x) => x.id === id);
129+
return n && (n.group_id ?? null) !== targetGroup;
130+
});
131+
const cleanedNames: string[] = [];
132+
for (const id of external) {
133+
const r = await invoke<MoveNoteResult>("move_note_to_group", { noteId: id, groupId: target.group_id });
134+
cleanedNames.push(...r.autoCleanedGroups);
135+
}
136+
// REQ-316(批 7):跨组拖走使源组变空 → 清理留痕(零清理零变化)
137+
if (cleanedNames.length > 0) onCleanNotice?.([...new Set(cleanedNames)]);
138+
// 落位(REQ-315:只写置顶区外子序列;落点在置顶行上 = 置顶区下沿即手动区首位)
139+
const pinned = new Set(notes.filter((n) => n.pin === 1).map((n) => n.id));
140+
const moved = ids.filter((id) => !pinned.has(id));
141+
if (moved.length === 0) {
142+
// 仅置顶成员拖拽:置顶区按更新时间定序不可移动——归组已完成即返回,不制造快照
143+
onNoteMoved?.();
144+
return;
145+
}
146+
const anchor = target.pin === 1 ? { head: true as const } : { targetId, before };
147+
const next = dropNotesIntoOrder(base, moved, anchor);
148+
if (!next) {
149+
// 目标不可见(折叠/异常)——至少完成归组,不写序
150+
onNoteMoved?.();
151+
return;
152+
}
153+
// 与既有序一致(如仅拖置顶行)= 跳过保存——防无变化操作制造无谓快照
154+
const prevBase = (manualOrders[scope] ?? []).filter((id) => {
155+
const n = notes.find((x) => x.id === id);
156+
return !n || n.pin !== 1;
157+
});
158+
if (prevBase.length === next.length && prevBase.every((id, i) => next[i] === id)) {
159+
onNoteMoved?.();
160+
return;
161+
}
162+
await saveOrder(scope, next);
163+
onNoteMoved?.();
164+
} catch (e) {
165+
console.warn("[notes] 行落点排序失败(部分操作可能已提交):", e);
166+
onNoteMoved?.();
167+
} finally {
168+
dropBusyRef.current = false;
169+
}
170+
}, [treeMode, notes, manualOrders, manualBaseOf, saveOrder, onNoteMoved, onCleanNotice]);
171+
172+
return { busyMove, moveToGroup, moveWithinScope, handleDropOnRow };
173+
}

0 commit comments

Comments
 (0)