@@ -104,6 +104,8 @@ export default function NoteListView({
104104 const [ manualOrders , setManualOrders ] = useState < Record < string , number [ ] > > ( { } ) ;
105105
106106 const visibleIdsRef = useRef < number [ ] > ( [ ] ) ;
107+ // L5:行落点并发锁(防陈旧快照互覆)
108+ const dropBusyRef = useRef ( false ) ;
107109
108110 const clearSelection = useCallback ( ( ) => {
109111 setSelection ( emptySelection ( ) ) ;
@@ -235,7 +237,16 @@ export default function NoteListView({
235237 return out ;
236238 } , [ treeMode , grouped , groups , notes , groupFolds , applyManual , theme , groupFilter ] ) ;
237239
238- const visibleOrder = useMemo ( ( ) => sections . flatMap ( ( s ) => s . items . map ( ( n ) => n . id ) ) , [ sections ] ) ;
240+ // 可见序(L1 审查:折叠组行不参与区间/划选——与渲染可见一致;折叠组头仍在)
241+ const visibleOrder = useMemo ( ( ) => {
242+ const out : number [ ] = [ ] ;
243+ for ( const sec of sections ) {
244+ const key = sec . groupId == null ? "none" : String ( sec . groupId ) ;
245+ if ( groupFolds [ key ] === true ) continue ; // 折叠=行不可见,排除出选择语义
246+ for ( const n of sec . items ) out . push ( n . id ) ;
247+ }
248+ return out ;
249+ } , [ sections , groupFolds ] ) ;
239250 useEffect ( ( ) => { visibleIdsRef . current = visibleOrder ; } , [ visibleOrder ] ) ;
240251
241252 // ── 行交互 ──
@@ -246,16 +257,25 @@ export default function NoteListView({
246257 setAnchor ( note . id ) ;
247258 return ;
248259 }
260+ // 审查 L4:普通单击=单选并打开——先清既有选集,anchor 恒指向本次点击
261+ if ( selection . size > 0 ) clearSelection ( ) ;
249262 onSelect ( note ) ;
250- if ( selection . size === 0 ) setAnchor ( note . id ) ;
251- } , [ selectionMode , onSelect , selection . size ] ) ;
263+ setAnchor ( note . id ) ;
264+ } , [ selectionMode , onSelect , selection . size , clearSelection ] ) ;
252265
253266 const handleModifierClick = useCallback ( ( note : Note , ctrl : boolean , shift : boolean ) => {
254267 if ( ctrl ) {
255268 setSelection ( ( cur ) => toggleSelection ( cur , note . id ) ) ;
269+ // 审查 L4:Ctrl 后 anchor 指向本次点击行(即使该行被移除——Explorer 同款)
256270 setAnchor ( note . id ) ;
257271 } else if ( shift ) {
258- setSelection ( ( cur ) => rangeSelection ( cur , visibleIdsRef . current , anchor , note . id ) ) ;
272+ if ( anchor == null ) {
273+ // 审查 L4:无锚的首次 Shift=单选该行并设为锚(连按两次不再各加单行)
274+ setSelection ( new Set ( [ note . id ] ) ) ;
275+ setAnchor ( note . id ) ;
276+ } else {
277+ setSelection ( ( cur ) => rangeSelection ( cur , visibleIdsRef . current , anchor , note . id ) ) ;
278+ }
259279 }
260280 } , [ anchor ] ) ;
261281
@@ -285,37 +305,62 @@ export default function NoteListView({
285305 }
286306 } , [ notes , manualOrders , saveOrder , onNoteMoved ] ) ;
287307
288- /** 行间落点(同 scope 手动排序;自动排序组=仅归组语义已在 header 处理) */
308+ /** 行间落点(同 scope 手动排序;跨组归入目标组后按落点插入——L2 审查修正:
309+ * 先归组、后整表覆写;目标不可见/无 ord 尾部不再静默 no-op) */
289310 const handleDropOnRow = useCallback ( async ( ids : number [ ] , targetId : number , before : boolean ) => {
311+ // L3:平铺态(搜索/标签/非默认排序)禁排序拖拽——矩阵锁定规则
312+ if ( ! treeMode ) return ;
313+ if ( dropBusyRef . current ) return ;
290314 const target = notes . find ( ( n ) => n . id === targetId ) ;
291- if ( ! target ) return ;
292- const scope = scopeKey ( target . group_id ?? null ) ;
293- const order = manualOrders [ scope ] ;
294- const movers = ids . filter ( ( id ) => ! order || order . includes ( id ) || notes . find ( ( n ) => n . id === id ) ?. group_id === target . group_id ) ;
295- const current = order ?? sections . find ( ( s ) => s . scope === scope ) ?. items . map ( ( n ) => n . id ) ?? [ ] ;
296- // 快照语义:首次拖=自动启用(当前可见序即快照)
297- const list = ( order ?? current ) . filter ( ( id ) => ! movers . includes ( id ) ) ;
298- const idx = list . indexOf ( targetId ) ;
299- if ( idx < 0 ) return ;
300- list . splice ( before ? idx : idx + 1 , 0 , ... movers ) ;
301- // 跨组落点:先把非本组笔记归入目标组
302- for ( const id of ids ) {
303- const n = notes . find ( ( x ) => x . id === id ) ;
304- if ( n && ( n . group_id ?? null ) !== ( target . group_id ?? null ) ) {
315+ if ( ! target || ids . includes ( targetId ) ) return ;
316+ dropBusyRef . current = true ;
317+ try {
318+ const targetGroup = target . group_id ?? null ;
319+ const scope = scopeKey ( targetGroup ) ;
320+ const currentList = manualOrders [ scope ]
321+ ?? sections . find ( ( s ) => s . scope === scope ) ?. items . map ( ( n ) => n . id )
322+ ?? [ ] ;
323+ // 跨组 id:先归入目标组(await 顺序执行——同事务语义由命令层保证)
324+ const external = ids . filter ( ( id ) => {
325+ const n = notes . find ( ( x ) => x . id === id ) ;
326+ return n && ( n . group_id ?? null ) !== targetGroup ;
327+ } ) ;
328+ for ( const id of external ) {
305329 await invoke ( "move_note_to_group" , { noteId : id , groupId : target . group_id } ) ;
306330 }
331+ // 落位(移出原序再插入;未启手排的 scope=快照语义自动启用)
332+ const list = currentList . filter ( ( id ) => ! ids . includes ( id ) ) ;
333+ const idx = list . indexOf ( targetId ) ;
334+ if ( idx < 0 ) {
335+ // 目标不可见(折叠/异常)——至少完成归组,不写序
336+ onNoteMoved ?.( ) ;
337+ return ;
338+ }
339+ list . splice ( before ? idx : idx + 1 , 0 , ...ids ) ;
340+ await saveOrder ( scope , list ) ;
341+ onNoteMoved ?.( ) ;
342+ } catch ( e ) {
343+ console . warn ( "[notes] 行落点排序失败(部分操作可能已提交):" , e ) ;
344+ onNoteMoved ?.( ) ;
345+ } finally {
346+ dropBusyRef . current = false ;
307347 }
308- await saveOrder ( scope , list ) ;
309- onNoteMoved ?.( ) ;
310- } , [ notes , manualOrders , sections , saveOrder , onNoteMoved ] ) ;
348+ } , [ treeMode , notes , manualOrders , sections , saveOrder , onNoteMoved ] ) ;
311349
312- /** 划选(组头空白起 → 组内首行至当前行带;走既有行命中的全局序) */
350+ /** 划选(组头空白起 → 组内首行至当前行带;走既有行命中的全局可见序)。
351+ * L9 审查修正:rAF 节流 + pointercancel/blur/松开(buttons=0)即清理——
352+ * 不再有"窗口外释放后监听永久残留、悬停任意行改写选区"的泄漏。 */
313353 const startMarquee = useCallback ( ( scope : string ) => {
354+ // 折叠组行不可见——不提供划选起点(避免选中不可见数据)
355+ const foldKey = scope === "none" ? "none" : scope . slice ( 2 ) ;
356+ if ( groupFolds [ foldKey ] === true ) return ;
314357 const section = sections . find ( ( s ) => s . scope === scope ) ;
315358 if ( ! section || section . items . length === 0 ) return ;
316359 const startGlobal = visibleOrder . indexOf ( section . items [ 0 ] . id ) ;
317360 if ( startGlobal < 0 ) return ;
318- const onMove = ( e : PointerEvent ) => {
361+
362+ let raf = 0 ;
363+ const hit = ( e : PointerEvent ) => {
319364 const el = document . elementFromPoint ( e . clientX , e . clientY ) as HTMLElement | null ;
320365 const rowEl = el ?. closest < HTMLElement > ( '[id^="note-row-"]' ) ;
321366 if ( ! rowEl ) return ;
@@ -326,13 +371,23 @@ export default function NoteListView({
326371 const hi = Math . max ( startGlobal , gi ) ;
327372 setSelection ( new Set ( visibleOrder . slice ( lo , hi + 1 ) ) ) ;
328373 } ;
329- const onUp = ( ) => {
374+ const onMove = ( e : PointerEvent ) => {
375+ if ( e . buttons === 0 ) { cleanup ( ) ; return ; }
376+ if ( raf ) return ;
377+ raf = requestAnimationFrame ( ( ) => { raf = 0 ; hit ( e ) ; } ) ;
378+ } ;
379+ const cleanup = ( ) => {
380+ if ( raf ) cancelAnimationFrame ( raf ) ;
330381 window . removeEventListener ( "pointermove" , onMove ) ;
331- window . removeEventListener ( "pointerup" , onUp ) ;
382+ window . removeEventListener ( "pointerup" , cleanup ) ;
383+ window . removeEventListener ( "pointercancel" , cleanup ) ;
384+ window . removeEventListener ( "blur" , cleanup ) ;
332385 } ;
333386 window . addEventListener ( "pointermove" , onMove ) ;
334- window . addEventListener ( "pointerup" , onUp ) ;
335- } , [ sections , visibleOrder ] ) ;
387+ window . addEventListener ( "pointerup" , cleanup ) ;
388+ window . addEventListener ( "pointercancel" , cleanup ) ;
389+ window . addEventListener ( "blur" , cleanup ) ;
390+ } , [ sections , visibleOrder , groupFolds ] ) ;
336391
337392 const rowAccent = ( n : Note ) => paletteHex ( noteColors ?. [ n . id ] ?? null , theme ) ;
338393
0 commit comments