paseo-kanban 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1393 @@
1
+ import { useRef, useSyncExternalStore, useState, useEffect } from "react";
2
+ import type { KanbanTask } from "../shared/kanban";
3
+
4
+ export interface Rect {
5
+ x: number;
6
+ y: number;
7
+ width: number;
8
+ height: number;
9
+ }
10
+
11
+ export interface LaneLayoutItem {
12
+ id: string;
13
+ rect: Rect;
14
+ }
15
+
16
+ export interface DragHitTestOptions {
17
+ pointerX: number;
18
+ pointerY: number;
19
+ containerBounds: Rect | null;
20
+ scrollX: number;
21
+ lanes: LaneLayoutItem[];
22
+ }
23
+
24
+ export function findHoveredLaneId(options: DragHitTestOptions): string | null {
25
+ const { pointerX, pointerY, containerBounds, scrollX, lanes } = options;
26
+
27
+ if (containerBounds) {
28
+ if (
29
+ pointerX < containerBounds.x ||
30
+ pointerX > containerBounds.x + containerBounds.width ||
31
+ pointerY < containerBounds.y ||
32
+ pointerY > containerBounds.y + containerBounds.height
33
+ ) {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ const containerOriginX = containerBounds ? containerBounds.x : 0;
39
+ const containerOriginY = containerBounds ? containerBounds.y : 0;
40
+ const contentX = pointerX - containerOriginX + scrollX;
41
+ const contentY = pointerY - containerOriginY;
42
+
43
+ for (const lane of lanes) {
44
+ const { x, y, width, height } = lane.rect;
45
+ if (
46
+ contentX >= x &&
47
+ contentX <= x + width &&
48
+ contentY >= y &&
49
+ contentY <= y + height
50
+ ) {
51
+ return lane.id;
52
+ }
53
+ }
54
+
55
+ return null;
56
+ }
57
+
58
+ export interface DragSlotState {
59
+ readonly isDragging: boolean;
60
+ readonly draggingTaskId: string | null;
61
+ readonly draggingSourceLaneId: string | null;
62
+ readonly hoveredLaneId: string | null;
63
+ readonly targetIndex: number;
64
+ readonly draggedCardHeight?: number;
65
+ readonly dragLock: boolean;
66
+ }
67
+
68
+ export interface DragFeedback {
69
+ readonly isDragging: boolean;
70
+ readonly draggingTaskId: string | null;
71
+ readonly draggingSourceLaneId: string | null;
72
+ readonly hoveredLaneId: string | null;
73
+ readonly targetIndex?: number;
74
+ readonly draggedCardHeight?: number;
75
+ readonly pointerX?: number;
76
+ readonly pointerY?: number;
77
+ readonly autoScrollVelocity?: number;
78
+ readonly dragLock: boolean;
79
+ }
80
+
81
+ export interface TaskPreviewItem {
82
+ task: KanbanTask | null;
83
+ projectDisplayName: string | null;
84
+ }
85
+
86
+ export interface KanbanDragOptions {
87
+ lanes?: ({ id: string } | string)[];
88
+ onMoveTask?: (taskId: string, targetLaneId: string) => void | Promise<void>;
89
+ onReorderTask?: (taskId: string, targetLaneId: string, targetIndex: number) => void | Promise<void>;
90
+ getTaskPreview?: (taskId: string) => TaskPreviewItem | null;
91
+ }
92
+
93
+ export interface ContainerRefTarget {
94
+ measureInWindow?: (
95
+ callback: (x: number, y: number, width: number, height: number) => void
96
+ ) => void;
97
+ }
98
+
99
+ export interface PanResponderFactory {
100
+ create: (config: any) => { panHandlers: any };
101
+ }
102
+
103
+ export interface CardDragBinding {
104
+ readonly isDragging: boolean;
105
+ readonly cardPanHandlers: any;
106
+ readonly handlePanHandlers: any;
107
+ readonly onLayout: (rect: Rect) => void;
108
+ readonly onUnmount: () => void;
109
+ }
110
+
111
+ export interface LaneDragBinding {
112
+ readonly isHovered: boolean;
113
+ readonly targetIndex: number;
114
+ readonly isTaskDragging: (taskId: string) => boolean;
115
+ readonly draggedCardHeight?: number;
116
+ readonly isDragLocked: () => boolean;
117
+ readonly bindCard: (
118
+ taskId: string,
119
+ onSelectTask?: () => void,
120
+ factory?: PanResponderFactory
121
+ ) => CardDragBinding;
122
+ readonly registerLaneLayout: (layout: Rect) => void;
123
+ readonly registerCardsViewport: (layout: Rect) => void;
124
+ readonly handleLaneScroll: (scrollY: number) => void;
125
+ readonly registerCardLayout: (taskId: string, layout: Rect) => void;
126
+ readonly unregisterCardLayout: (taskId: string) => void;
127
+ readonly setDropSpacerY: (y: number | null) => void;
128
+ readonly setLaneCardOrder: (laneIdOrTaskIds: string | string[], taskIds?: string[]) => void;
129
+ readonly startGesture: (
130
+ taskId: string,
131
+ sourceLaneId: string,
132
+ pointerX: number,
133
+ pointerY: number,
134
+ immediate?: boolean
135
+ ) => void;
136
+ readonly moveGesture: (pointerX: number, pointerY: number) => void;
137
+ readonly releaseGesture: (pointerX?: number, pointerY?: number) => void;
138
+ readonly cancelGesture: () => void;
139
+ }
140
+
141
+ interface ActiveDragSession {
142
+ taskId: string;
143
+ sourceLaneId: string;
144
+ startX: number;
145
+ startY: number;
146
+ lastPointerX: number;
147
+ lastPointerY: number;
148
+ isActivated: boolean;
149
+ currentTargetIndex?: number;
150
+ cardLayouts: Map<string, Map<string, Rect>>;
151
+ }
152
+
153
+ export class KanbanDragController {
154
+ private lanesList: string[] = [];
155
+ private validLaneSet: Set<string> = new Set();
156
+ private laneLayouts: Map<string, Rect> = new Map();
157
+ private laneCardOrders: Map<string, string[]> = new Map();
158
+ private cardLayouts: Map<string, Map<string, Rect>> = new Map();
159
+ private cardsViewportLayouts: Map<string, Rect> = new Map();
160
+ private laneScrollY: Map<string, number> = new Map();
161
+ private containerBounds: Rect | null = null;
162
+ private containerRef: ContainerRefTarget | null = null;
163
+ private scrollX: number = 0;
164
+ private activeSession: ActiveDragSession | null = null;
165
+ private onMoveTask?: (taskId: string, targetLaneId: string) => void | Promise<void>;
166
+ private onReorderTask?: (taskId: string, targetLaneId: string, targetIndex: number) => void | Promise<void>;
167
+ private dragLock: boolean = false;
168
+ private dragLockTimeout: ReturnType<typeof setTimeout> | null = null;
169
+ private pendingTimer: ReturnType<typeof setTimeout> | null = null;
170
+ private pendingOnPress: (() => void) | null = null;
171
+ private listeners: Set<() => void> = new Set();
172
+ private slotListeners: Set<() => void> = new Set();
173
+ private dropListeners: Set<() => void> = new Set();
174
+ private dropSpacerY: number | null = null;
175
+ private releaseHandler?: (releaseX?: number, releaseY?: number) => Promise<void>;
176
+ private cancelHandler?: () => void;
177
+ private panResponderFactory: PanResponderFactory | null = null;
178
+ private cardCallbacks: Map<string, () => void> = new Map();
179
+ private cardResponders: Map<string, { body: any; handle: any }> = new Map();
180
+ private pendingInputKind: "mouse" | "touch" = "mouse";
181
+ private operationSeq: number = 0;
182
+ private currentOperationId: string | null = null;
183
+ private currentDroppingState: DroppingState | null = null;
184
+ private getTaskPreview?: (taskId: string) => TaskPreviewItem | null;
185
+
186
+ private feedback: DragFeedback = {
187
+ isDragging: false,
188
+ draggingTaskId: null,
189
+ draggingSourceLaneId: null,
190
+ hoveredLaneId: null,
191
+ dragLock: false,
192
+ };
193
+
194
+ private slotState: DragSlotState = {
195
+ isDragging: false,
196
+ draggingTaskId: null,
197
+ draggingSourceLaneId: null,
198
+ hoveredLaneId: null,
199
+ targetIndex: 0,
200
+ dragLock: false,
201
+ };
202
+
203
+ constructor(options?: KanbanDragOptions) {
204
+ if (options?.lanes) {
205
+ this.setLanes(options.lanes);
206
+ }
207
+ if (options?.onMoveTask) {
208
+ this.setOnMoveTask(options.onMoveTask);
209
+ }
210
+ if (options?.onReorderTask) {
211
+ this.setOnReorderTask(options.onReorderTask);
212
+ }
213
+ if (options?.getTaskPreview) {
214
+ this.getTaskPreview = options.getTaskPreview;
215
+ }
216
+ }
217
+
218
+ setLanes = (lanes: ({ id: string } | string)[]) => {
219
+ this.lanesList = lanes.map((l) => (typeof l === "string" ? l : l.id));
220
+ this.validLaneSet = new Set(this.lanesList);
221
+ if (this.activeSession && this.activeSession.isActivated) {
222
+ this.recomputeHover(this.activeSession.lastPointerX, this.activeSession.lastPointerY);
223
+ }
224
+ };
225
+
226
+ setOnMoveTask = (fn: (taskId: string, targetLaneId: string) => void | Promise<void>) => {
227
+ this.onMoveTask = fn;
228
+ };
229
+
230
+ setOnReorderTask = (fn: (taskId: string, targetLaneId: string, targetIndex: number) => void | Promise<void>) => {
231
+ this.onReorderTask = fn;
232
+ };
233
+
234
+ setGetTaskPreview = (fn?: (taskId: string) => TaskPreviewItem | null) => {
235
+ this.getTaskPreview = fn;
236
+ };
237
+
238
+ setLaneCardOrder = (laneId: string, taskIds: string[]) => {
239
+ this.laneCardOrders.set(laneId, taskIds);
240
+ };
241
+
242
+ registerCardLayout = (laneId: string, taskId: string, rect: Rect) => {
243
+ let laneMap = this.cardLayouts.get(laneId);
244
+ if (!laneMap) {
245
+ laneMap = new Map();
246
+ this.cardLayouts.set(laneId, laneMap);
247
+ }
248
+ laneMap.set(taskId, rect);
249
+ };
250
+
251
+ unregisterCardLayout = (laneId: string, taskId: string) => {
252
+ const laneMap = this.cardLayouts.get(laneId);
253
+ if (laneMap) {
254
+ laneMap.delete(taskId);
255
+ }
256
+ };
257
+
258
+ isDragLocked = (): boolean => {
259
+ return this.dragLock;
260
+ };
261
+
262
+ registerContainerBounds = (bounds: Rect | null) => {
263
+ this.containerBounds = bounds;
264
+ if (this.activeSession) {
265
+ this.recomputeHover(this.activeSession.lastPointerX, this.activeSession.lastPointerY);
266
+ }
267
+ };
268
+
269
+ bindContainerRef = (ref: ContainerRefTarget | null) => {
270
+ this.containerRef = ref;
271
+ };
272
+
273
+ measureContainer = () => {
274
+ if (this.containerRef?.measureInWindow) {
275
+ this.containerRef.measureInWindow((wx, wy, ww, wh) => {
276
+ if (ww > 0 && wh > 0) {
277
+ this.containerBounds = { x: wx, y: wy, width: ww, height: wh };
278
+ if (this.activeSession) {
279
+ this.recomputeHover(this.activeSession.lastPointerX, this.activeSession.lastPointerY);
280
+ }
281
+ }
282
+ });
283
+ }
284
+ };
285
+
286
+ handleContainerLayout = (layout: Rect) => {
287
+ if (!this.containerBounds) {
288
+ this.containerBounds = layout;
289
+ }
290
+ this.measureContainer();
291
+ };
292
+
293
+ handleScroll = (scrollX: number) => {
294
+ this.scrollX = scrollX;
295
+ if (this.activeSession) {
296
+ this.recomputeHover(this.activeSession.lastPointerX, this.activeSession.lastPointerY);
297
+ }
298
+ };
299
+
300
+ registerLaneLayout = (laneId: string, rect: Rect) => {
301
+ this.laneLayouts.set(laneId, rect);
302
+ };
303
+
304
+ registerCardsViewportLayout = (laneId: string, rect: Rect) => {
305
+ this.cardsViewportLayouts.set(laneId, rect);
306
+ };
307
+
308
+ handleLaneScroll = (laneId: string, scrollY: number) => {
309
+ this.laneScrollY.set(laneId, scrollY);
310
+ if (this.activeSession) {
311
+ this.recomputeHover(this.activeSession.lastPointerX, this.activeSession.lastPointerY);
312
+ }
313
+ };
314
+
315
+ setDropSpacerY = (y: number | null) => {
316
+ this.dropSpacerY = y;
317
+ };
318
+
319
+ getDropSpacerY = (): number | null => {
320
+ return this.dropSpacerY;
321
+ };
322
+
323
+ setReleaseHandler = (fn?: (releaseX?: number, releaseY?: number) => Promise<void>) => {
324
+ this.releaseHandler = fn;
325
+ };
326
+
327
+ setCancelHandler = (fn?: () => void) => {
328
+ this.cancelHandler = fn;
329
+ };
330
+
331
+ setPanResponderFactory = (factory: PanResponderFactory) => {
332
+ this.panResponderFactory = factory;
333
+ };
334
+
335
+ bindCard = (
336
+ laneId: string,
337
+ taskId: string,
338
+ onPress?: () => void,
339
+ panResponderFactory?: PanResponderFactory
340
+ ): CardDragBinding => {
341
+ const key = `${laneId}:${taskId}`;
342
+ if (onPress) {
343
+ this.cardCallbacks.set(key, onPress);
344
+ } else {
345
+ this.cardCallbacks.delete(key);
346
+ }
347
+
348
+ const factory = panResponderFactory ?? this.panResponderFactory;
349
+ let responders = this.cardResponders.get(key);
350
+ if (!responders && factory) {
351
+ const body = factory.create({
352
+ onStartShouldSetPanResponder: () => true,
353
+ onMoveShouldSetPanResponder: () => true,
354
+ onPanResponderGrant: (_e: any, gs: any) => {
355
+ const cb = this.cardCallbacks.get(key);
356
+ this.handlePointerDown(
357
+ { taskId, laneId, onPress: cb },
358
+ { x: gs.x0, y: gs.y0 },
359
+ "body"
360
+ );
361
+ },
362
+ onPanResponderMove: (_e: any, gs: any) => {
363
+ this.handlePointerMove({ x: gs.moveX, y: gs.moveY });
364
+ },
365
+ onPanResponderRelease: () => this.handlePointerUp(),
366
+ onPanResponderTerminate: () => {
367
+ this.handlePointerCancel();
368
+ },
369
+ onPanResponderTerminationRequest: () => !this.isTaskDragging(taskId),
370
+ });
371
+
372
+ const handle = factory.create({
373
+ onStartShouldSetPanResponder: () => true,
374
+ onMoveShouldSetPanResponder: () => true,
375
+ onPanResponderGrant: (_e: any, gs: any) => {
376
+ const cb = this.cardCallbacks.get(key);
377
+ this.handlePointerDown(
378
+ { taskId, laneId, onPress: cb },
379
+ { x: gs.x0, y: gs.y0 },
380
+ "handle"
381
+ );
382
+ },
383
+ onPanResponderMove: (_e: any, gs: any) => {
384
+ this.handlePointerMove({ x: gs.moveX, y: gs.moveY });
385
+ },
386
+ onPanResponderRelease: () => this.handlePointerUp(),
387
+ onPanResponderTerminate: () => {
388
+ this.handlePointerCancel();
389
+ },
390
+ });
391
+
392
+ responders = { body, handle };
393
+ this.cardResponders.set(key, responders);
394
+ }
395
+
396
+ return {
397
+ isDragging: this.isTaskDragging(taskId),
398
+ cardPanHandlers: responders?.body?.panHandlers,
399
+ handlePanHandlers: responders?.handle?.panHandlers,
400
+ onLayout: (rect: Rect) => this.registerCardLayout(laneId, taskId, rect),
401
+ onUnmount: () => {
402
+ this.cardCallbacks.delete(key);
403
+ this.cardResponders.delete(key);
404
+ this.unregisterCardLayout(laneId, taskId);
405
+ },
406
+ };
407
+ };
408
+
409
+ private clearPendingTimer = () => {
410
+ if (this.pendingTimer) {
411
+ clearTimeout(this.pendingTimer);
412
+ this.pendingTimer = null;
413
+ }
414
+ };
415
+
416
+ handlePointerDown = (
417
+ context: { taskId: string; laneId: string; onPress?: () => void },
418
+ point: { x: number; y: number },
419
+ source: "handle" | "body" = "body",
420
+ inputKind?: "mouse" | "touch"
421
+ ) => {
422
+ if (this.dragLock) return;
423
+ if (this.activeSession?.isActivated) return;
424
+ if (this.currentDroppingState) return;
425
+
426
+ this.clearPendingTimer();
427
+ this.pendingOnPress = context.onPress ?? null;
428
+ this.pendingInputKind = inputKind ?? "mouse";
429
+
430
+ if (source === "handle") {
431
+ this.startGesture(context.taskId, context.laneId, point.x, point.y, true);
432
+ return;
433
+ }
434
+
435
+ this.startGesture(context.taskId, context.laneId, point.x, point.y, false);
436
+
437
+ const longPressDuration = inputKind === "touch" ? 220 : 260;
438
+ this.pendingTimer = setTimeout(() => {
439
+ this.pendingTimer = null;
440
+ if (this.dragLock) return;
441
+ if (this.activeSession && !this.activeSession.isActivated) {
442
+ this.activeSession.isActivated = true;
443
+ this.dragLock = true;
444
+ const initialHit = this.computeHit(
445
+ this.activeSession.lastPointerX,
446
+ this.activeSession.lastPointerY
447
+ );
448
+ const targetLaneId = initialHit ?? this.activeSession.sourceLaneId;
449
+ const targetIndex = this.computeTargetIndex(
450
+ targetLaneId,
451
+ this.activeSession.lastPointerY
452
+ );
453
+ this.activeSession.currentTargetIndex = targetIndex;
454
+ this.feedback = {
455
+ ...this.feedback,
456
+ isDragging: true,
457
+ draggingTaskId: this.activeSession.taskId,
458
+ draggingSourceLaneId: this.activeSession.sourceLaneId,
459
+ hoveredLaneId: targetLaneId,
460
+ targetIndex,
461
+ draggedCardHeight: this.activeSession.cardLayouts
462
+ .get(this.activeSession.sourceLaneId)
463
+ ?.get(this.activeSession.taskId)?.height,
464
+ dragLock: true,
465
+ };
466
+ this.notifyListeners();
467
+ }
468
+ }, longPressDuration);
469
+ };
470
+
471
+ handlePointerMove = (point: { x: number; y: number }) => {
472
+ if (!this.activeSession) return;
473
+ const dist = Math.hypot(
474
+ point.x - this.activeSession.startX,
475
+ point.y - this.activeSession.startY
476
+ );
477
+
478
+ // If unactivated touch gesture and user moved >= 5px before 220ms:
479
+ // User is scrolling! Cancel pending gesture, do NOT activate drag, do NOT trigger tap!
480
+ if (!this.activeSession.isActivated && this.pendingInputKind === "touch") {
481
+ if (dist >= 5) {
482
+ this.clearPendingTimer();
483
+ this.pendingOnPress = null;
484
+ this.cancelGesture();
485
+ return;
486
+ }
487
+ }
488
+
489
+ if (dist >= 5) {
490
+ this.clearPendingTimer();
491
+ }
492
+ this.moveGesture(point.x, point.y);
493
+ };
494
+
495
+ handlePointerUp = async (point?: { x: number; y: number }): Promise<void> => {
496
+ this.clearPendingTimer();
497
+ if (!this.activeSession) return;
498
+
499
+ point ??= {
500
+ x: this.activeSession.lastPointerX,
501
+ y: this.activeSession.lastPointerY,
502
+ };
503
+ const wasActivated = this.activeSession.isActivated;
504
+ const dist = Math.hypot(
505
+ point.x - this.activeSession.startX,
506
+ point.y - this.activeSession.startY
507
+ );
508
+ const onPress = this.pendingOnPress;
509
+ this.pendingOnPress = null;
510
+
511
+ if (wasActivated) {
512
+ if (this.releaseHandler) {
513
+ await this.releaseHandler(point?.x, point?.y);
514
+ } else {
515
+ await this.releaseGesture(point?.x, point?.y);
516
+ }
517
+ } else {
518
+ await this.releaseGesture(point?.x, point?.y);
519
+ if (dist < 5 && !this.dragLock && onPress) {
520
+ onPress();
521
+ }
522
+ }
523
+ };
524
+
525
+ handlePointerCancel = () => {
526
+ this.clearPendingTimer();
527
+ this.pendingOnPress = null;
528
+ if (this.cancelHandler) {
529
+ this.cancelHandler();
530
+ } else {
531
+ this.cancelGesture();
532
+ }
533
+ };
534
+
535
+ startGesture = (
536
+ taskId: string,
537
+ sourceLaneId: string,
538
+ pointerX: number,
539
+ pointerY: number,
540
+ immediate: boolean = true
541
+ ) => {
542
+ if (this.dragLockTimeout) {
543
+ clearTimeout(this.dragLockTimeout);
544
+ this.dragLockTimeout = null;
545
+ }
546
+ this.measureContainer();
547
+
548
+ const isActivated = immediate;
549
+ if (isActivated) {
550
+ this.dragLock = true;
551
+ }
552
+
553
+ this.activeSession = {
554
+ taskId,
555
+ sourceLaneId,
556
+ startX: pointerX,
557
+ startY: pointerY,
558
+ lastPointerX: pointerX,
559
+ lastPointerY: pointerY,
560
+ isActivated,
561
+ currentTargetIndex: undefined,
562
+ cardLayouts: new Map([...this.cardLayouts].map(([id, cards]) => [id, new Map(cards)])),
563
+ };
564
+
565
+ if (isActivated) {
566
+ const initialHit = this.computeHit(pointerX, pointerY);
567
+ const targetLaneId = initialHit ?? sourceLaneId;
568
+ const targetIndex = this.computeTargetIndex(targetLaneId, pointerY);
569
+ this.activeSession.currentTargetIndex = targetIndex;
570
+ const autoScrollVelocity = this.computeAutoScrollVelocity(pointerX);
571
+
572
+ this.feedback = {
573
+ isDragging: true,
574
+ draggingTaskId: taskId,
575
+ draggingSourceLaneId: sourceLaneId,
576
+ hoveredLaneId: targetLaneId,
577
+ targetIndex,
578
+ draggedCardHeight: this.activeSession.cardLayouts.get(sourceLaneId)?.get(taskId)?.height,
579
+ pointerX,
580
+ pointerY,
581
+ autoScrollVelocity,
582
+ dragLock: true,
583
+ };
584
+ } else {
585
+ this.feedback = {
586
+ isDragging: false,
587
+ draggingTaskId: taskId,
588
+ draggingSourceLaneId: sourceLaneId,
589
+ hoveredLaneId: null,
590
+ targetIndex: undefined,
591
+ pointerX,
592
+ pointerY,
593
+ autoScrollVelocity: 0,
594
+ dragLock: false,
595
+ };
596
+ }
597
+ this.notifyListeners();
598
+ };
599
+
600
+ moveGesture = (pointerX: number, pointerY: number) => {
601
+ if (!this.activeSession) return;
602
+
603
+ if (pointerX > 0 || pointerY > 0) {
604
+ this.activeSession.lastPointerX = pointerX;
605
+ this.activeSession.lastPointerY = pointerY;
606
+ }
607
+
608
+ if (!this.activeSession.isActivated) {
609
+ const dist = Math.hypot(
610
+ this.activeSession.lastPointerX - this.activeSession.startX,
611
+ this.activeSession.lastPointerY - this.activeSession.startY
612
+ );
613
+ if (dist >= 5) {
614
+ this.activeSession.isActivated = true;
615
+ this.dragLock = true;
616
+ } else {
617
+ return;
618
+ }
619
+ }
620
+
621
+ const hitLaneId = this.computeHit(
622
+ this.activeSession.lastPointerX,
623
+ this.activeSession.lastPointerY
624
+ );
625
+ const targetLaneId = hitLaneId ?? this.activeSession.sourceLaneId;
626
+ const targetIndex = this.computeTargetIndex(targetLaneId, this.activeSession.lastPointerY);
627
+ this.activeSession.currentTargetIndex = targetIndex;
628
+ const autoScrollVelocity = this.computeAutoScrollVelocity(this.activeSession.lastPointerX);
629
+
630
+ this.feedback = {
631
+ isDragging: true,
632
+ draggingTaskId: this.activeSession.taskId,
633
+ draggingSourceLaneId: this.activeSession.sourceLaneId,
634
+ hoveredLaneId: hitLaneId,
635
+ targetIndex,
636
+ draggedCardHeight: this.activeSession.cardLayouts.get(this.activeSession.sourceLaneId)?.get(this.activeSession.taskId)?.height,
637
+ pointerX: this.activeSession.lastPointerX,
638
+ pointerY: this.activeSession.lastPointerY,
639
+ autoScrollVelocity,
640
+ dragLock: true,
641
+ };
642
+ this.notifyListeners();
643
+ };
644
+
645
+ releaseGesture = async (pointerX?: number, pointerY?: number): Promise<void> => {
646
+ const session = this.activeSession;
647
+ if (!session) return;
648
+
649
+ if (!session.isActivated) {
650
+ this.activeSession = null;
651
+ this.dragLock = false;
652
+ this.feedback = {
653
+ isDragging: false,
654
+ draggingTaskId: null,
655
+ draggingSourceLaneId: null,
656
+ hoveredLaneId: null,
657
+ targetIndex: undefined,
658
+ pointerX: undefined,
659
+ pointerY: undefined,
660
+ autoScrollVelocity: 0,
661
+ dragLock: false,
662
+ };
663
+ this.notifyListeners();
664
+ return;
665
+ }
666
+
667
+ const drop = await this.beginDropAnimation({
668
+ x: pointerX ?? session.lastPointerX,
669
+ y: pointerY ?? session.lastPointerY,
670
+ });
671
+
672
+ if (drop) {
673
+ await this.reportDropComplete(drop.operationId);
674
+ }
675
+ };
676
+
677
+ beginDropAnimation = async (point?: { x?: number; y?: number }): Promise<DroppingState | null> => {
678
+ const session = this.activeSession;
679
+ if (!session || !session.isActivated) {
680
+ this.clearPendingTimer();
681
+ this.cancelGesture();
682
+ return null;
683
+ }
684
+
685
+ const finalX =
686
+ typeof point?.x === "number" && !isNaN(point.x) && point.x > 0
687
+ ? point.x
688
+ : session.lastPointerX;
689
+ const finalY =
690
+ typeof point?.y === "number" && !isNaN(point.y) && point.y > 0
691
+ ? point.y
692
+ : session.lastPointerY;
693
+
694
+ const hitLaneId = this.computeHit(finalX, finalY);
695
+ const targetLaneId = hitLaneId ?? session.sourceLaneId;
696
+ const targetIndex = this.computeTargetIndex(targetLaneId, finalY);
697
+
698
+ const isValidTarget = Boolean(hitLaneId && this.validLaneSet.has(hitLaneId));
699
+ const isDrop = isValidTarget;
700
+
701
+ const opId = `op_${++this.operationSeq}_${Date.now()}`;
702
+ this.currentOperationId = opId;
703
+
704
+ const containerX = this.containerBounds?.x ?? 0;
705
+ const containerY = this.containerBounds?.y ?? 0;
706
+ const cardHalfWidth = 140;
707
+
708
+ const fromX = finalX - containerX - cardHalfWidth;
709
+ const fromY = finalY - containerY - 20;
710
+
711
+ let toX = fromX;
712
+ let toY = fromY;
713
+
714
+ if (isDrop) {
715
+ const targetSlot = this.getDropSlotPosition(targetLaneId, targetIndex);
716
+ if (targetSlot) {
717
+ toX = targetSlot.x;
718
+ const laneScroll = this.getLaneScroll(targetLaneId);
719
+ const laneLayout = this.getLaneLayout(targetLaneId);
720
+ const cardsViewport = this.getCardsViewportLayout(targetLaneId);
721
+ const dropSpacerY = this.getDropSpacerY();
722
+ if (dropSpacerY !== null && hitLaneId === targetLaneId) {
723
+ toY =
724
+ (laneLayout?.y ?? 0) +
725
+ (cardsViewport?.y ?? 0) -
726
+ laneScroll +
727
+ dropSpacerY;
728
+ } else {
729
+ toY = targetSlot.y;
730
+ }
731
+ }
732
+ } else {
733
+ const sourceIndex = Math.max(0, this.laneCardOrders.get(session.sourceLaneId)?.indexOf(session.taskId) ?? 0);
734
+ const sourceSlot = this.getDropSlotPosition(session.sourceLaneId, sourceIndex);
735
+ if (sourceSlot) {
736
+ toX = sourceSlot.x;
737
+ toY = sourceSlot.y;
738
+ }
739
+ }
740
+
741
+ const preview = this.getTaskPreview ? this.getTaskPreview(session.taskId) : null;
742
+
743
+ const droppingState: DroppingState = {
744
+ operationId: opId,
745
+ taskId: session.taskId,
746
+ sourceLaneId: session.sourceLaneId,
747
+ task: preview?.task ?? null,
748
+ projectName: preview?.projectDisplayName ?? null,
749
+ targetLaneId,
750
+ targetIndex,
751
+ isDrop,
752
+ fromX,
753
+ fromY,
754
+ toX,
755
+ toY,
756
+ releaseX: finalX,
757
+ releaseY: finalY,
758
+ };
759
+
760
+ const savedCardHeight = this.feedback.draggedCardHeight;
761
+ this.currentDroppingState = droppingState;
762
+ this.activeSession = null;
763
+ this.dragLock = true;
764
+ this.feedback = {
765
+ isDragging: false,
766
+ draggingTaskId: null,
767
+ draggingSourceLaneId: null,
768
+ hoveredLaneId: null,
769
+ targetIndex: undefined,
770
+ pointerX: undefined,
771
+ pointerY: undefined,
772
+ autoScrollVelocity: 0,
773
+ dragLock: true,
774
+ draggedCardHeight: savedCardHeight,
775
+ };
776
+ this.notifyListeners();
777
+ this.notifyDropListeners();
778
+ return droppingState;
779
+ };
780
+
781
+ reportDropComplete = async (operationId: string): Promise<void> => {
782
+ if (!this.currentOperationId || this.currentOperationId !== operationId) {
783
+ return;
784
+ }
785
+
786
+ const drop = this.currentDroppingState;
787
+ this.currentOperationId = null;
788
+
789
+ try {
790
+ if (drop && drop.isDrop && this.validLaneSet.has(drop.targetLaneId)) {
791
+ if (this.onReorderTask) {
792
+ await this.onReorderTask(drop.taskId, drop.targetLaneId, drop.targetIndex);
793
+ } else if (this.onMoveTask && drop.targetLaneId !== drop.sourceLaneId) {
794
+ await this.onMoveTask(drop.taskId, drop.targetLaneId);
795
+ }
796
+ }
797
+ } finally {
798
+ this.currentDroppingState = null;
799
+ this.dropSpacerY = null;
800
+ this.notifyDropListeners();
801
+ this.notifyListeners();
802
+
803
+ if (this.dragLockTimeout) clearTimeout(this.dragLockTimeout);
804
+ this.dragLockTimeout = setTimeout(() => {
805
+ this.dragLock = false;
806
+ this.feedback = {
807
+ ...this.feedback,
808
+ dragLock: false,
809
+ };
810
+ this.notifyListeners();
811
+ }, 60);
812
+ }
813
+ };
814
+
815
+ abortDrop = (operationId: string) => {
816
+ if (!this.currentOperationId || this.currentOperationId !== operationId) {
817
+ return;
818
+ }
819
+ this.currentOperationId = null;
820
+ this.currentDroppingState = null;
821
+ this.dropSpacerY = null;
822
+ this.notifyDropListeners();
823
+ this.notifyListeners();
824
+
825
+ if (this.dragLockTimeout) clearTimeout(this.dragLockTimeout);
826
+ this.dragLockTimeout = setTimeout(() => {
827
+ this.dragLock = false;
828
+ this.feedback = {
829
+ ...this.feedback,
830
+ dragLock: false,
831
+ };
832
+ this.notifyListeners();
833
+ }, 60);
834
+ };
835
+
836
+ cancelGesture = () => {
837
+ this.clearPendingTimer();
838
+ this.pendingOnPress = null;
839
+ if (!this.activeSession) return;
840
+ const wasActivated = this.activeSession.isActivated;
841
+ this.activeSession = null;
842
+ this.feedback = {
843
+ isDragging: false,
844
+ draggingTaskId: null,
845
+ draggingSourceLaneId: null,
846
+ hoveredLaneId: null,
847
+ targetIndex: undefined,
848
+ pointerX: undefined,
849
+ pointerY: undefined,
850
+ autoScrollVelocity: 0,
851
+ dragLock: wasActivated,
852
+ };
853
+ this.notifyListeners();
854
+
855
+ if (wasActivated) {
856
+ if (this.dragLockTimeout) clearTimeout(this.dragLockTimeout);
857
+ this.dragLockTimeout = setTimeout(() => {
858
+ this.dragLock = false;
859
+ this.feedback = {
860
+ ...this.feedback,
861
+ dragLock: false,
862
+ };
863
+ this.notifyListeners();
864
+ }, 60);
865
+ }
866
+ };
867
+
868
+ getContainerBounds = (): Rect | null => {
869
+ return this.containerBounds;
870
+ };
871
+
872
+ getLaneLayout = (laneId: string): Rect | undefined => {
873
+ return this.laneLayouts.get(laneId);
874
+ };
875
+
876
+ getCardsViewportLayout = (laneId: string): Rect | undefined => {
877
+ return this.cardsViewportLayouts.get(laneId);
878
+ };
879
+
880
+ getLaneScroll = (laneId: string): number => {
881
+ return this.laneScrollY.get(laneId) ?? 0;
882
+ };
883
+
884
+ getDropSlotPosition = (
885
+ laneId: string,
886
+ targetIndex: number
887
+ ): { x: number; y: number } | null => {
888
+ const laneRect = this.laneLayouts.get(laneId);
889
+ if (!laneRect) return null;
890
+
891
+ const cardsViewport = this.cardsViewportLayouts.get(laneId);
892
+ const scrollY = this.laneScrollY.get(laneId) ?? 0;
893
+ const laneCardsMap = (this.activeSession?.cardLayouts ?? this.cardLayouts).get(laneId);
894
+
895
+ const slotX = laneRect.x - this.scrollX + (cardsViewport?.x ?? 12);
896
+ const activeTaskId =
897
+ this.activeSession?.taskId ??
898
+ this.feedback.draggingTaskId ??
899
+ this.currentDroppingState?.taskId;
900
+ const allCardIds = this.laneCardOrders.get(laneId) ?? [];
901
+ const remainingCardIds = allCardIds.filter((id) => id !== activeTaskId);
902
+
903
+ const laneOriginY = laneRect.y;
904
+ const cardsOriginY = cardsViewport?.y ?? 0;
905
+
906
+ let slotCardY = 0;
907
+ if (remainingCardIds.length === 0 || targetIndex <= 0) {
908
+ slotCardY = 0;
909
+ } else if (targetIndex < remainingCardIds.length) {
910
+ const nextCardId = remainingCardIds[targetIndex];
911
+ const nextCardRect = laneCardsMap?.get(nextCardId);
912
+ if (nextCardRect) {
913
+ slotCardY = nextCardRect.y;
914
+ } else {
915
+ const prevCardId = remainingCardIds[targetIndex - 1];
916
+ const prevRect = laneCardsMap?.get(prevCardId);
917
+ slotCardY = prevRect ? prevRect.y + prevRect.height + 8 : targetIndex * 80;
918
+ }
919
+ } else {
920
+ const lastCardId = remainingCardIds[remainingCardIds.length - 1];
921
+ const lastCardRect = laneCardsMap?.get(lastCardId);
922
+ slotCardY = lastCardRect ? lastCardRect.y + lastCardRect.height + 8 : remainingCardIds.length * 80;
923
+ }
924
+
925
+ const slotY = laneOriginY + cardsOriginY - scrollY + slotCardY;
926
+ return { x: slotX, y: slotY };
927
+ };
928
+
929
+ getFeedback = (): DragFeedback => {
930
+ return this.feedback;
931
+ };
932
+
933
+ getSlotState = (): DragSlotState => {
934
+ return this.slotState;
935
+ };
936
+
937
+ isTaskDragging = (taskId: string): boolean => {
938
+ if (this.currentDroppingState && this.currentDroppingState.taskId === taskId) {
939
+ return true;
940
+ }
941
+ return this.feedback.isDragging && this.feedback.draggingTaskId === taskId;
942
+ };
943
+
944
+ isLaneHovered = (laneId: string): boolean => {
945
+ if (this.currentDroppingState) {
946
+ return this.currentDroppingState.targetLaneId === laneId;
947
+ }
948
+ return this.feedback.hoveredLaneId === laneId && this.feedback.isDragging;
949
+ };
950
+
951
+ subscribe = (listener: () => void): (() => void) => {
952
+ this.listeners.add(listener);
953
+ return () => {
954
+ this.listeners.delete(listener);
955
+ };
956
+ };
957
+
958
+ subscribeSlot = (listener: () => void): (() => void) => {
959
+ this.slotListeners.add(listener);
960
+ return () => {
961
+ this.slotListeners.delete(listener);
962
+ };
963
+ };
964
+
965
+ subscribeDrop = (listener: () => void): (() => void) => {
966
+ this.dropListeners.add(listener);
967
+ return () => {
968
+ this.dropListeners.delete(listener);
969
+ };
970
+ };
971
+
972
+ getDroppingState = (): DroppingState | null => {
973
+ return this.currentDroppingState;
974
+ };
975
+
976
+ private notifyDropListeners() {
977
+ for (const listener of this.dropListeners) {
978
+ listener();
979
+ }
980
+ }
981
+
982
+ private checkAndNotifySlotState() {
983
+ const isDropping = this.currentDroppingState !== null;
984
+ const nextSlotState: DragSlotState = {
985
+ isDragging: this.feedback.isDragging || isDropping,
986
+ draggingTaskId:
987
+ this.feedback.draggingTaskId ??
988
+ (isDropping ? this.currentDroppingState?.taskId ?? null : null),
989
+ draggingSourceLaneId:
990
+ this.feedback.draggingSourceLaneId ??
991
+ (isDropping ? this.currentDroppingState?.sourceLaneId ?? null : null),
992
+ hoveredLaneId:
993
+ this.feedback.hoveredLaneId ??
994
+ (isDropping ? this.currentDroppingState?.targetLaneId ?? null : null),
995
+ targetIndex:
996
+ this.feedback.targetIndex ??
997
+ (isDropping ? this.currentDroppingState?.targetIndex ?? 0 : 0),
998
+ draggedCardHeight: this.feedback.draggedCardHeight,
999
+ dragLock: this.feedback.dragLock,
1000
+ };
1001
+
1002
+ const slotChanged =
1003
+ this.slotState.isDragging !== nextSlotState.isDragging ||
1004
+ this.slotState.draggingTaskId !== nextSlotState.draggingTaskId ||
1005
+ this.slotState.draggingSourceLaneId !== nextSlotState.draggingSourceLaneId ||
1006
+ this.slotState.hoveredLaneId !== nextSlotState.hoveredLaneId ||
1007
+ this.slotState.targetIndex !== nextSlotState.targetIndex ||
1008
+ this.slotState.draggedCardHeight !== nextSlotState.draggedCardHeight ||
1009
+ this.slotState.dragLock !== nextSlotState.dragLock;
1010
+
1011
+ if (slotChanged) {
1012
+ this.slotState = nextSlotState;
1013
+ for (const listener of this.slotListeners) {
1014
+ listener();
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ private notifyListeners() {
1020
+ this.checkAndNotifySlotState();
1021
+ for (const listener of this.listeners) {
1022
+ listener();
1023
+ }
1024
+ }
1025
+
1026
+ private computeAutoScrollVelocity(pointerX: number): number {
1027
+ if (!this.containerBounds) return 0;
1028
+ const relX = pointerX - this.containerBounds.x;
1029
+ if (relX < 48) {
1030
+ return Math.max(-1, Math.min(0, (relX - 48) / 48));
1031
+ }
1032
+ if (relX > this.containerBounds.width - 48) {
1033
+ return Math.min(1, Math.max(0, (relX - (this.containerBounds.width - 48)) / 48));
1034
+ }
1035
+ return 0;
1036
+ }
1037
+
1038
+ private computeTargetIndex(laneId: string, pointerY: number): number {
1039
+ const allCardIds = this.laneCardOrders.get(laneId) ?? [];
1040
+ const activeTaskId = this.activeSession?.taskId;
1041
+ const isSourceLane = this.activeSession ? this.activeSession.sourceLaneId === laneId : false;
1042
+
1043
+ const remainingCount = isSourceLane && allCardIds.includes(activeTaskId ?? "")
1044
+ ? Math.max(0, allCardIds.length - 1)
1045
+ : allCardIds.length;
1046
+
1047
+ if (allCardIds.length === 0 || remainingCount === 0) return 0;
1048
+
1049
+ const laneRect = this.laneLayouts.get(laneId);
1050
+ const containerOriginY = this.containerBounds?.y ?? 0;
1051
+ const laneOriginY = laneRect?.y ?? 0;
1052
+ const cardsOriginY = this.cardsViewportLayouts.get(laneId)?.y ?? 0;
1053
+ const scrollY = this.laneScrollY.get(laneId) ?? 0;
1054
+ const laneCardsMap = (this.activeSession?.cardLayouts ?? this.cardLayouts).get(laneId);
1055
+
1056
+ const prevIndex = this.feedback.hoveredLaneId === laneId
1057
+ ? this.activeSession?.currentTargetIndex ?? -1
1058
+ : -1;
1059
+
1060
+ for (let i = 0; i < allCardIds.length; i++) {
1061
+ const cardId = allCardIds[i];
1062
+ const cardRect = laneCardsMap?.get(cardId);
1063
+ if (!cardRect) continue;
1064
+
1065
+ const cardTop = containerOriginY + laneOriginY + cardsOriginY - scrollY + cardRect.y;
1066
+ const cardBottom = cardTop + cardRect.height;
1067
+
1068
+ let thresholdY: number;
1069
+ if (i < allCardIds.length - 1) {
1070
+ const nextCardId = allCardIds[i + 1];
1071
+ const nextCardRect = laneCardsMap?.get(nextCardId);
1072
+ if (nextCardRect) {
1073
+ const nextCardTop = containerOriginY + laneOriginY + cardsOriginY - scrollY + nextCardRect.y;
1074
+ thresholdY = (cardBottom + nextCardTop) / 2;
1075
+ } else {
1076
+ thresholdY = cardBottom + 4;
1077
+ }
1078
+ } else {
1079
+ thresholdY = cardBottom + 4;
1080
+ }
1081
+
1082
+ const lowerBuffer = thresholdY - 8;
1083
+ const upperBuffer = thresholdY + 8;
1084
+
1085
+ if (pointerY < lowerBuffer) {
1086
+ return Math.min(i, remainingCount);
1087
+ }
1088
+ if (pointerY <= upperBuffer) {
1089
+ if (prevIndex === i) {
1090
+ return Math.min(i, remainingCount);
1091
+ }
1092
+ if (prevIndex === i + 1) {
1093
+ return Math.min(i + 1, remainingCount);
1094
+ }
1095
+ return pointerY >= thresholdY
1096
+ ? Math.min(i + 1, remainingCount)
1097
+ : Math.min(i, remainingCount);
1098
+ }
1099
+ }
1100
+
1101
+ return remainingCount;
1102
+ }
1103
+
1104
+ private computeHit(pointerX: number, pointerY: number): string | null {
1105
+ const laneItems: LaneLayoutItem[] = [];
1106
+ for (const laneId of this.lanesList) {
1107
+ const rect = this.laneLayouts.get(laneId);
1108
+ if (rect) {
1109
+ laneItems.push({ id: laneId, rect });
1110
+ }
1111
+ }
1112
+
1113
+ const hitId = findHoveredLaneId({
1114
+ pointerX,
1115
+ pointerY,
1116
+ containerBounds: this.containerBounds,
1117
+ scrollX: this.scrollX,
1118
+ lanes: laneItems,
1119
+ });
1120
+
1121
+ if (hitId && this.validLaneSet.has(hitId)) {
1122
+ return hitId;
1123
+ }
1124
+ return null;
1125
+ }
1126
+
1127
+ private recomputeHover(pointerX: number, pointerY: number) {
1128
+ if (!this.activeSession?.isActivated) return;
1129
+ const hitLaneId = this.computeHit(pointerX, pointerY);
1130
+ const targetIndex = hitLaneId ? this.computeTargetIndex(hitLaneId, pointerY) : undefined;
1131
+ this.activeSession.currentTargetIndex = targetIndex;
1132
+ if (this.feedback.hoveredLaneId !== hitLaneId || this.feedback.targetIndex !== targetIndex) {
1133
+ this.feedback = {
1134
+ ...this.feedback,
1135
+ hoveredLaneId: hitLaneId,
1136
+ targetIndex,
1137
+ };
1138
+ this.notifyListeners();
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ export interface DroppingState {
1144
+ readonly operationId: string;
1145
+ readonly taskId: string;
1146
+ readonly sourceLaneId: string;
1147
+ readonly task: KanbanTask | null;
1148
+ readonly projectName: string | null;
1149
+ readonly targetLaneId: string;
1150
+ readonly targetIndex: number;
1151
+ readonly isDrop: boolean;
1152
+ readonly fromX: number;
1153
+ readonly fromY: number;
1154
+ readonly toX: number;
1155
+ readonly toY: number;
1156
+ readonly releaseX?: number;
1157
+ readonly releaseY?: number;
1158
+ }
1159
+
1160
+ export interface UseKanbanDragReturn {
1161
+ controller: KanbanDragController;
1162
+ slotState: DragSlotState;
1163
+ feedback: DragFeedback;
1164
+ isDropping: boolean;
1165
+ droppingTaskId: string | null;
1166
+ droppingState: DroppingState | null;
1167
+ commitDrop: () => Promise<void>;
1168
+ abortDrop: () => void;
1169
+ getTaskPreview?: (taskId: string) => TaskPreviewItem | null;
1170
+ setGetTaskPreview?: (fn?: (taskId: string) => TaskPreviewItem | null) => void;
1171
+ bindLane: (laneId: string) => LaneDragBinding;
1172
+ bindContainerRef: (instance: any) => void;
1173
+ handleContainerLayout: (layout: Rect) => void;
1174
+ handleContainerScroll: (scrollX: number) => void;
1175
+ getContainerBounds: () => Rect | null;
1176
+ startGesture: KanbanDragController["startGesture"];
1177
+ moveGesture: KanbanDragController["moveGesture"];
1178
+ releaseGesture: (releaseX?: number, releaseY?: number) => Promise<void>;
1179
+ cancelGesture: () => void;
1180
+ registerContainerBounds: KanbanDragController["registerContainerBounds"];
1181
+ handleScroll: KanbanDragController["handleScroll"];
1182
+ registerLaneLayout: KanbanDragController["registerLaneLayout"];
1183
+ registerCardsViewportLayout: KanbanDragController["registerCardsViewportLayout"];
1184
+ handleLaneScroll: KanbanDragController["handleLaneScroll"];
1185
+ registerCardLayout: KanbanDragController["registerCardLayout"];
1186
+ unregisterCardLayout: KanbanDragController["unregisterCardLayout"];
1187
+ setLaneCardOrder: KanbanDragController["setLaneCardOrder"];
1188
+ isTaskDragging: (taskId: string) => boolean;
1189
+ isLaneHovered: (laneId: string) => boolean;
1190
+ isDragLocked: () => boolean;
1191
+ getLaneLayout: KanbanDragController["getLaneLayout"];
1192
+ getCardsViewportLayout: KanbanDragController["getCardsViewportLayout"];
1193
+ getLaneScroll: KanbanDragController["getLaneScroll"];
1194
+ getDropSlotPosition: KanbanDragController["getDropSlotPosition"];
1195
+ }
1196
+
1197
+ export function useKanbanDrag(options: KanbanDragOptions = {}): UseKanbanDragReturn {
1198
+ const { lanes = [], onMoveTask, onReorderTask, getTaskPreview } = options;
1199
+ const controllerRef = useRef<KanbanDragController | null>(null);
1200
+ if (!controllerRef.current) {
1201
+ controllerRef.current = new KanbanDragController({
1202
+ lanes,
1203
+ onMoveTask,
1204
+ onReorderTask,
1205
+ });
1206
+ }
1207
+ const controller = controllerRef.current;
1208
+
1209
+ controller.setLanes(lanes);
1210
+ if (onMoveTask) {
1211
+ controller.setOnMoveTask(onMoveTask);
1212
+ }
1213
+ if (onReorderTask) {
1214
+ controller.setOnReorderTask(onReorderTask);
1215
+ }
1216
+ if (getTaskPreview) {
1217
+ controller.setGetTaskPreview(getTaskPreview);
1218
+ }
1219
+
1220
+ const slotState = useSyncExternalStore(
1221
+ controller.subscribeSlot,
1222
+ controller.getSlotState,
1223
+ controller.getSlotState
1224
+ );
1225
+
1226
+ const feedback = useSyncExternalStore(
1227
+ controller.subscribe,
1228
+ controller.getFeedback,
1229
+ controller.getFeedback
1230
+ );
1231
+
1232
+ const droppingState = useSyncExternalStore(
1233
+ controller.subscribeDrop,
1234
+ controller.getDroppingState,
1235
+ controller.getDroppingState
1236
+ );
1237
+
1238
+ const containerScrollRef = useRef<any>(null);
1239
+ const currentScrollX = useRef(0);
1240
+
1241
+ // Smooth edge auto-scroll when dragging near viewport boundaries
1242
+ useEffect(() => {
1243
+ const curFeedback = controller.getFeedback();
1244
+ const velocity = curFeedback.autoScrollVelocity ?? 0;
1245
+ if (!velocity || !curFeedback.isDragging) return;
1246
+
1247
+ const timer = setInterval(() => {
1248
+ const liveFeedback = controller.getFeedback();
1249
+ const curVelocity = liveFeedback.autoScrollVelocity ?? 0;
1250
+ if (!curVelocity || !liveFeedback.isDragging) return;
1251
+ const nextOffset = Math.max(0, currentScrollX.current + curVelocity * 14);
1252
+ currentScrollX.current = nextOffset;
1253
+ containerScrollRef.current?.scrollTo({ x: nextOffset, animated: false });
1254
+ }, 16);
1255
+
1256
+ return () => clearInterval(timer);
1257
+ }, [slotState.isDragging, controller]);
1258
+
1259
+ const handleContainerLayout = (layout: Rect) => {
1260
+ controller.handleContainerLayout(layout);
1261
+ };
1262
+
1263
+ const handleContainerScroll = (scrollX: number) => {
1264
+ currentScrollX.current = scrollX;
1265
+ controller.handleScroll(scrollX);
1266
+ };
1267
+
1268
+ const bindContainerRef = (instance: any) => {
1269
+ containerScrollRef.current = instance;
1270
+ controller.bindContainerRef(instance);
1271
+ };
1272
+
1273
+ const commitDrop = async () => {
1274
+ const drop = controller.getDroppingState();
1275
+ if (drop) {
1276
+ await controller.reportDropComplete(drop.operationId);
1277
+ }
1278
+ };
1279
+
1280
+ const abortDrop = () => {
1281
+ const drop = controller.getDroppingState();
1282
+ if (drop) {
1283
+ controller.abortDrop(drop.operationId);
1284
+ }
1285
+ };
1286
+
1287
+ const handleDragRelease = async (releaseX?: number, releaseY?: number): Promise<void> => {
1288
+ await controller.beginDropAnimation({ x: releaseX, y: releaseY });
1289
+ };
1290
+
1291
+ const handleDragCancel = () => {
1292
+ const drop = controller.getDroppingState();
1293
+ if (drop) {
1294
+ controller.abortDrop(drop.operationId);
1295
+ } else {
1296
+ controller.cancelGesture();
1297
+ }
1298
+ };
1299
+
1300
+ controller.setReleaseHandler(handleDragRelease);
1301
+ controller.setCancelHandler(handleDragCancel);
1302
+
1303
+ const isTaskDragging = (taskId: string): boolean => {
1304
+ if (droppingState?.taskId === taskId) return true;
1305
+ return controller.isTaskDragging(taskId);
1306
+ };
1307
+
1308
+ const isLaneHovered = (laneId: string): boolean => {
1309
+ if (droppingState) {
1310
+ return droppingState.targetLaneId === laneId;
1311
+ }
1312
+ return controller.isLaneHovered(laneId);
1313
+ };
1314
+
1315
+ const isDragLocked = (): boolean => {
1316
+ return controller.isDragLocked() || droppingState !== null;
1317
+ };
1318
+
1319
+ const bindLane = (laneId: string): LaneDragBinding => {
1320
+ const isHovered = droppingState
1321
+ ? droppingState.targetLaneId === laneId
1322
+ : slotState.isDragging && slotState.hoveredLaneId === laneId;
1323
+ const targetIndex = droppingState
1324
+ ? (droppingState.targetLaneId === laneId ? droppingState.targetIndex : -1)
1325
+ : isHovered
1326
+ ? slotState.targetIndex
1327
+ : -1;
1328
+
1329
+ return {
1330
+ isHovered,
1331
+ targetIndex,
1332
+ isTaskDragging,
1333
+ draggedCardHeight: slotState.draggedCardHeight,
1334
+ isDragLocked,
1335
+ bindCard: (taskId: string, onSelectTask?: () => void, factory?: PanResponderFactory) =>
1336
+ controller.bindCard(laneId, taskId, onSelectTask, factory),
1337
+ registerLaneLayout: (layout: Rect) => controller.registerLaneLayout(laneId, layout),
1338
+ registerCardsViewport: (layout: Rect) => controller.registerCardsViewportLayout(laneId, layout),
1339
+ handleLaneScroll: (scrollY: number) => controller.handleLaneScroll(laneId, scrollY),
1340
+ registerCardLayout: (taskId: string, layout: Rect) => controller.registerCardLayout(laneId, taskId, layout),
1341
+ unregisterCardLayout: (taskId: string) => controller.unregisterCardLayout(laneId, taskId),
1342
+ setDropSpacerY: (y: number | null) => controller.setDropSpacerY(y),
1343
+ setLaneCardOrder: (laneIdOrTaskIds: string | string[], taskIds?: string[]) => {
1344
+ if (Array.isArray(laneIdOrTaskIds)) {
1345
+ controller.setLaneCardOrder(laneId, laneIdOrTaskIds);
1346
+ } else if (taskIds) {
1347
+ controller.setLaneCardOrder(laneIdOrTaskIds, taskIds);
1348
+ }
1349
+ },
1350
+ startGesture: controller.startGesture,
1351
+ moveGesture: controller.moveGesture,
1352
+ releaseGesture: handleDragRelease,
1353
+ cancelGesture: handleDragCancel,
1354
+ };
1355
+ };
1356
+
1357
+ return {
1358
+ controller,
1359
+ slotState,
1360
+ feedback,
1361
+ isDropping: droppingState !== null,
1362
+ droppingTaskId: droppingState?.taskId ?? null,
1363
+ droppingState,
1364
+ commitDrop,
1365
+ abortDrop,
1366
+ getTaskPreview,
1367
+ setGetTaskPreview: controller.setGetTaskPreview,
1368
+ bindLane,
1369
+ bindContainerRef,
1370
+ handleContainerLayout,
1371
+ handleContainerScroll,
1372
+ getContainerBounds: controller.getContainerBounds,
1373
+ startGesture: controller.startGesture,
1374
+ moveGesture: controller.moveGesture,
1375
+ releaseGesture: handleDragRelease,
1376
+ cancelGesture: handleDragCancel,
1377
+ registerContainerBounds: controller.registerContainerBounds,
1378
+ handleScroll: controller.handleScroll,
1379
+ registerLaneLayout: controller.registerLaneLayout,
1380
+ registerCardsViewportLayout: controller.registerCardsViewportLayout,
1381
+ handleLaneScroll: controller.handleLaneScroll,
1382
+ registerCardLayout: controller.registerCardLayout,
1383
+ unregisterCardLayout: controller.unregisterCardLayout,
1384
+ setLaneCardOrder: controller.setLaneCardOrder,
1385
+ isTaskDragging,
1386
+ isLaneHovered,
1387
+ isDragLocked,
1388
+ getLaneLayout: controller.getLaneLayout,
1389
+ getCardsViewportLayout: controller.getCardsViewportLayout,
1390
+ getLaneScroll: controller.getLaneScroll,
1391
+ getDropSlotPosition: controller.getDropSlotPosition,
1392
+ };
1393
+ }