@@ -13,7 +13,6 @@ const logger = createLogger('WorkflowList:DragDrop')
1313const SCROLL_THRESHOLD = 60
1414const SCROLL_SPEED = 8
1515const HOVER_EXPAND_DELAY = 400
16- const DRAG_OVER_THROTTLE_MS = 16
1716
1817export interface DropIndicator {
1918 targetId : string
@@ -32,21 +31,35 @@ type SiblingItem = {
3231 createdAt : Date
3332}
3433
34+ /** Root folder vs root workflow scope: API/cache may use null or undefined for "no parent". */
35+ function isSameFolderScope (
36+ parentOrFolderId : string | null | undefined ,
37+ scope : string | null
38+ ) : boolean {
39+ return ( parentOrFolderId ?? null ) === ( scope ?? null )
40+ }
41+
3542export function useDragDrop ( options : UseDragDropOptions = { } ) {
3643 const { disabled = false } = options
3744 const [ dropIndicator , setDropIndicator ] = useState < DropIndicator | null > ( null )
45+ /**
46+ * Mirrors `dropIndicator` synchronously. `drop` can fire before React commits the last
47+ * `dragOver` state update, so `handleDrop` must read this ref instead of state.
48+ */
49+ const dropIndicatorRef = useRef < DropIndicator | null > ( null )
3850 const [ isDragging , setIsDragging ] = useState ( false )
3951 const [ hoverFolderId , setHoverFolderId ] = useState < string | null > ( null )
4052 const scrollContainerRef = useRef < HTMLDivElement | null > ( null )
4153 const scrollAnimationRef = useRef < number | null > ( null )
4254 const hoverExpandTimerRef = useRef < number | null > ( null )
4355 const lastDragYRef = useRef < number > ( 0 )
44- const lastDragOverTimeRef = useRef < number > ( 0 )
4556 const draggedSourceFolderRef = useRef < string | null > ( null )
4657 const siblingsCacheRef = useRef < Map < string , SiblingItem [ ] > > ( new Map ( ) )
58+ const isDraggingRef = useRef ( false )
4759
4860 const params = useParams ( )
4961 const workspaceId = params . workspaceId as string | undefined
62+
5063 const reorderWorkflowsMutation = useReorderWorkflows ( )
5164 const reorderFoldersMutation = useReorderFolders ( )
5265 const setExpanded = useFolderStore ( ( s ) => s . setExpanded )
@@ -127,6 +140,10 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
127140 }
128141 } , [ hoverFolderId , isDragging , expandedFolders , setExpanded ] )
129142
143+ useEffect ( ( ) => {
144+ siblingsCacheRef . current . clear ( )
145+ } , [ workspaceId ] )
146+
130147 const calculateDropPosition = useCallback (
131148 ( e : React . DragEvent , element : HTMLElement ) : 'before' | 'after' => {
132149 const rect = element . getBoundingClientRect ( )
@@ -164,12 +181,28 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
164181 : indicator . folderId
165182 } , [ ] )
166183
167- const calculateInsertIndex = useCallback (
168- ( remaining : SiblingItem [ ] , indicator : DropIndicator ) : number => {
169- return indicator . position === 'inside'
170- ? remaining . length
171- : remaining . findIndex ( ( item ) => item . id === indicator . targetId ) +
172- ( indicator . position === 'after' ? 1 : 0 )
184+ /**
185+ * Insert index into the list of siblings **excluding** moving items. Must use the full
186+ * `siblingItems` list for lookup: when the drop line targets the dragged row,
187+ * `indicator.targetId` is not present in `remaining`, so indexing `remaining` alone
188+ * returns -1 and corrupts the splice.
189+ */
190+ const getInsertIndexInRemaining = useCallback (
191+ ( siblingItems : SiblingItem [ ] , movingIds : Set < string > , indicator : DropIndicator ) : number => {
192+ if ( indicator . position === 'inside' ) {
193+ return siblingItems . filter ( ( s ) => ! movingIds . has ( s . id ) ) . length
194+ }
195+
196+ const targetIdx = siblingItems . findIndex ( ( s ) => s . id === indicator . targetId )
197+ if ( targetIdx === - 1 ) {
198+ return siblingItems . filter ( ( s ) => ! movingIds . has ( s . id ) ) . length
199+ }
200+
201+ if ( indicator . position === 'before' ) {
202+ return siblingItems . slice ( 0 , targetIdx ) . filter ( ( s ) => ! movingIds . has ( s . id ) ) . length
203+ }
204+
205+ return siblingItems . slice ( 0 , targetIdx + 1 ) . filter ( ( s ) => ! movingIds . has ( s . id ) ) . length
173206 } ,
174207 [ ]
175208 )
@@ -217,57 +250,65 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
217250 lastDragYRef . current = e . clientY
218251
219252 if ( ! isDragging ) {
253+ isDraggingRef . current = true
220254 setIsDragging ( true )
221255 }
222256
223- const now = performance . now ( )
224- if ( now - lastDragOverTimeRef . current < DRAG_OVER_THROTTLE_MS ) {
225- return false
226- }
227- lastDragOverTimeRef . current = now
228257 return true
229258 } ,
230259 [ isDragging ]
231260 )
232261
233- const getSiblingItems = useCallback ( ( folderId : string | null ) : SiblingItem [ ] => {
234- const cacheKey = folderId ?? 'root'
235- const cached = siblingsCacheRef . current . get ( cacheKey )
236- if ( cached ) return cached
237-
238- const currentFolders = workspaceId ? getFolderMap ( workspaceId ) : { }
239- const currentWorkflows = workspaceId ? getWorkflows ( workspaceId ) : [ ]
240- const siblings = [
241- ...Object . values ( currentFolders )
242- . filter ( ( f ) => f . parentId === folderId )
243- . map ( ( f ) => ( {
244- type : 'folder' as const ,
245- id : f . id ,
246- sortOrder : f . sortOrder ,
247- createdAt : f . createdAt ,
248- } ) ) ,
249- ...currentWorkflows
250- . filter ( ( w ) => w . folderId === folderId )
251- . map ( ( w ) => ( {
252- type : 'workflow' as const ,
253- id : w . id ,
254- sortOrder : w . sortOrder ,
255- createdAt : w . createdAt ,
256- } ) ) ,
257- ] . sort ( compareSiblingItems )
258-
259- siblingsCacheRef . current . set ( cacheKey , siblings )
260- return siblings
261- } , [ ] )
262+ const getSiblingItems = useCallback (
263+ ( folderId : string | null ) : SiblingItem [ ] => {
264+ const cacheKey = folderId ?? 'root'
265+ if ( ! isDraggingRef . current ) {
266+ const cached = siblingsCacheRef . current . get ( cacheKey )
267+ if ( cached ) return cached
268+ }
269+
270+ const currentFolders = workspaceId ? getFolderMap ( workspaceId ) : { }
271+ const currentWorkflows = workspaceId ? getWorkflows ( workspaceId ) : [ ]
272+ const siblings = [
273+ ...Object . values ( currentFolders )
274+ . filter ( ( f ) => isSameFolderScope ( f . parentId , folderId ) )
275+ . map ( ( f ) => ( {
276+ type : 'folder' as const ,
277+ id : f . id ,
278+ sortOrder : f . sortOrder ,
279+ createdAt : f . createdAt ,
280+ } ) ) ,
281+ ...currentWorkflows
282+ . filter ( ( w ) => isSameFolderScope ( w . folderId , folderId ) )
283+ . map ( ( w ) => ( {
284+ type : 'workflow' as const ,
285+ id : w . id ,
286+ sortOrder : w . sortOrder ,
287+ createdAt : w . createdAt ,
288+ } ) ) ,
289+ ] . sort ( compareSiblingItems )
290+
291+ if ( ! isDraggingRef . current ) {
292+ siblingsCacheRef . current . set ( cacheKey , siblings )
293+ }
294+ return siblings
295+ } ,
296+ [ workspaceId ]
297+ )
262298
263299 const setNormalizedDropIndicator = useCallback (
264300 ( indicator : DropIndicator | null ) => {
265- setDropIndicator ( ( prev ) => {
266- let next : DropIndicator | null = indicator
301+ if ( indicator === null ) {
302+ dropIndicatorRef . current = null
303+ setDropIndicator ( null )
304+ return
305+ }
267306
268- if ( indicator && indicator . position === 'after' && indicator . targetId !== 'root' ) {
269- const siblings = getSiblingItems ( indicator . folderId )
270- const currentIdx = siblings . findIndex ( ( s ) => s . id === indicator . targetId )
307+ let next : DropIndicator = indicator
308+ if ( indicator . position === 'after' && indicator . targetId !== 'root' ) {
309+ const siblings = getSiblingItems ( indicator . folderId )
310+ const currentIdx = siblings . findIndex ( ( s ) => s . id === indicator . targetId )
311+ if ( currentIdx !== - 1 ) {
271312 const nextSibling = siblings [ currentIdx + 1 ]
272313 if ( nextSibling ) {
273314 next = {
@@ -277,15 +318,18 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
277318 }
278319 }
279320 }
321+ }
280322
323+ setDropIndicator ( ( prev ) => {
281324 if (
282- prev ?. targetId === next ? .targetId &&
283- prev ?. position === next ? .position &&
284- prev ?. folderId === next ? .folderId
325+ prev ?. targetId === next . targetId &&
326+ prev ?. position === next . position &&
327+ prev ?. folderId === next . folderId
285328 ) {
329+ dropIndicatorRef . current = prev
286330 return prev
287331 }
288-
332+ dropIndicatorRef . current = next
289333 return next
290334 } )
291335 } ,
@@ -324,7 +368,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
324368 sortOrder : workflow . sortOrder ,
325369 createdAt : workflow . createdAt ,
326370 }
327- if ( workflow . folderId === destinationFolderId ) {
371+ if ( isSameFolderScope ( workflow . folderId , destinationFolderId ) ) {
328372 fromDestination . push ( item )
329373 } else {
330374 fromOther . push ( item )
@@ -340,7 +384,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
340384 sortOrder : folder . sortOrder ,
341385 createdAt : folder . createdAt ,
342386 }
343- if ( folder . parentId === destinationFolderId ) {
387+ if ( isSameFolderScope ( folder . parentId , destinationFolderId ) ) {
344388 fromDestination . push ( item )
345389 } else {
346390 fromOther . push ( item )
@@ -352,7 +396,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
352396
353397 return { fromDestination, fromOther }
354398 } ,
355- [ ]
399+ [ workspaceId ]
356400 )
357401
358402 const handleSelectionDrop = useCallback (
@@ -365,7 +409,9 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
365409 try {
366410 const destinationFolderId = getDestinationFolderId ( indicator )
367411 const validFolderIds = folderIds . filter ( ( id ) => canMoveFolderTo ( id , destinationFolderId ) )
368- if ( workflowIds . length === 0 && validFolderIds . length === 0 ) return
412+ if ( workflowIds . length === 0 && validFolderIds . length === 0 ) {
413+ return
414+ }
369415
370416 const siblingItems = getSiblingItems ( destinationFolderId )
371417 const movingIds = new Set ( [ ...workflowIds , ...validFolderIds ] )
@@ -377,7 +423,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
377423 destinationFolderId
378424 )
379425
380- const insertAt = calculateInsertIndex ( remaining , indicator )
426+ const insertAt = getInsertIndexInRemaining ( siblingItems , movingIds , indicator )
381427 const newOrder = [
382428 ...remaining . slice ( 0 , insertAt ) ,
383429 ...fromDestination ,
@@ -400,7 +446,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
400446 canMoveFolderTo ,
401447 getSiblingItems ,
402448 collectMovingItems ,
403- calculateInsertIndex ,
449+ getInsertIndexInRemaining ,
404450 buildAndSubmitUpdates ,
405451 ]
406452 )
@@ -410,8 +456,10 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
410456 e . preventDefault ( )
411457 e . stopPropagation ( )
412458
413- const indicator = dropIndicator
459+ const indicator = dropIndicatorRef . current
460+ dropIndicatorRef . current = null
414461 setDropIndicator ( null )
462+ isDraggingRef . current = false
415463 setIsDragging ( false )
416464 siblingsCacheRef . current . clear ( )
417465
@@ -430,7 +478,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
430478 logger . error ( 'Failed to handle drop:' , error )
431479 }
432480 } ,
433- [ dropIndicator , handleSelectionDrop ]
481+ [ handleSelectionDrop ]
434482 )
435483
436484 const createWorkflowDragHandlers = useCallback (
@@ -538,7 +586,9 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
538586 onDragOver : ( e : React . DragEvent < HTMLElement > ) => {
539587 if ( ! initDragOver ( e ) ) return
540588 if ( itemId ) {
541- setDropIndicator ( { targetId : itemId , position, folderId : null } )
589+ const edge : DropIndicator = { targetId : itemId , position, folderId : null }
590+ dropIndicatorRef . current = edge
591+ setDropIndicator ( edge )
542592 } else {
543593 setNormalizedDropIndicator ( { targetId : 'root' , position : 'inside' , folderId : null } )
544594 }
@@ -551,11 +601,15 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
551601
552602 const handleDragStart = useCallback ( ( sourceFolderId : string | null ) => {
553603 draggedSourceFolderRef . current = sourceFolderId
604+ siblingsCacheRef . current . clear ( )
605+ isDraggingRef . current = true
554606 setIsDragging ( true )
555607 } , [ ] )
556608
557609 const handleDragEnd = useCallback ( ( ) => {
610+ isDraggingRef . current = false
558611 setIsDragging ( false )
612+ dropIndicatorRef . current = null
559613 setDropIndicator ( null )
560614 draggedSourceFolderRef . current = null
561615 setHoverFolderId ( null )
0 commit comments