1414 * 区间/划选唯一基准;跨组语义=归组(目标手排时按落点插入)。
1515 */
1616import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
17- import { invoke } from "@tauri-apps/api/core" ;
1817import type { Note , NoteGroup } from "../types" ;
19- // REQ-316(批 7):移组返回契约(空组自动清理留痕数据源)
20- import type { MoveNoteResult } from "../types/notes" ;
2118import { paletteHex } from "../utils/colorPalette" ;
2219import type { ThemeMode } from "../utils/colorPalette" ;
2320import { emptySelection , rangeSelection , toggleSelection } from "../utils/noteSelection" ;
24- // REQ-315:scope 内 置顶→手排→自动 排序纯函数 + 显式移动(树视图 pin 生效)
25- import { dropNotesIntoOrder , shiftNoteOrder } from "../utils/noteOrder" ;
2621// 批 0-C2:展示节/可见序纯函数层(含 scope 键与裸折叠键派生——纯逻辑与副作用分离)
2722import { buildSections , buildVisibleOrder , isTreeMode , manualBaseIds , scopeKey } from "../utils/noteSectionModel" ;
2823// 批 0-C2:序行 store(笔记手排序行 + 组手排序行)——兑现既有登记拆分计划
2924import { useNoteOrders } from "../hooks/useNoteOrders" ;
25+ // 批 0-C2:拖拽/移动三入口接线(共享一把行落点并发锁——硬性同文件约束)
26+ import { useNoteMoves } from "../hooks/useNoteMoves" ;
3027import NoteListRow from "./NoteListRow" ;
3128import NoteTreeSection from "./NoteTreeSection" ;
3229import 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)即清理——
0 commit comments