@noya-app/noya-designsystem 0.1.56 → 0.1.58

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.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ Active,
2
3
  closestCenter,
3
4
  CollisionDetection,
4
5
  DndContext,
@@ -6,11 +7,15 @@ import {
6
7
  DragMoveEvent,
7
8
  DragOverlay,
8
9
  DragStartEvent,
10
+ Over,
9
11
  PointerSensor,
10
12
  pointerWithin,
11
13
  useSensor,
12
14
  useSensors,
13
15
  } from "@dnd-kit/core";
16
+ import { Point, rectContainsPoint, unionRects } from "@noya-app/noya-geometry";
17
+ import { windowsOf } from "@noya-app/noya-utils";
18
+ import { useStableCallback } from "@noya-app/react-utils";
14
19
  import * as React from "react";
15
20
  import { useMemo } from "react";
16
21
  import { createPortal } from "react-dom";
@@ -20,19 +25,28 @@ import {
20
25
  AnyDragContext,
21
26
  DragRegistrationContext,
22
27
  DragRegistrationContextType,
23
- SharedDragProviderContexts,
28
+ RegisteredList,
29
+ SharedDragProps,
24
30
  useDragRegistrationManager,
25
31
  } from "./DragRegistration";
26
- import { DragItem } from "./sorting";
32
+ import {
33
+ DragItem,
34
+ DragState,
35
+ MoveDragItemHandler,
36
+ MoveDragItemParameters,
37
+ RelativeDropPosition,
38
+ } from "./sorting";
27
39
 
28
40
  type SharedDragProviderProps = {
29
41
  children: React.ReactNode;
30
- contexts?: SharedDragProviderContexts;
42
+ onMoveItem?: MoveDragItemHandler;
43
+ sharedDragProps?: SharedDragProps;
31
44
  };
32
45
 
33
46
  export function SharedDragProvider({
34
47
  children,
35
- contexts = {
48
+ onMoveItem,
49
+ sharedDragProps = {
36
50
  dragRegistrationContext: DragRegistrationContext,
37
51
  activeDragContext: ActiveDragContext,
38
52
  },
@@ -46,28 +60,19 @@ export function SharedDragProvider({
46
60
  );
47
61
 
48
62
  const mounted = useMounted();
49
- const [activeItem, setActiveItem] = React.useState<DragItem | null>(null);
50
- const [delta, setDelta] = React.useState({ x: 0, y: 0 });
63
+ const [dragState, setDragState] = React.useState<DragState>({
64
+ source: null,
65
+ target: null,
66
+ indicator: null,
67
+ });
68
+
69
+ // console.log("dragState", dragState.target);
70
+
51
71
  const { registerList, unregisterList, registeredLists } =
52
72
  useDragRegistrationManager();
53
73
 
54
74
  const activatorEventRef = React.useRef<PointerEvent | null>(null);
55
75
 
56
- const acceptsListDrop = React.useCallback(
57
- (sourceListId: string, targetListId: string) => {
58
- const sourceList = registeredLists.get(sourceListId);
59
- const targetList = registeredLists.get(targetListId);
60
-
61
- if (!sourceList || !targetList) return false;
62
-
63
- return targetList.acceptsFromList({
64
- sourceListId,
65
- targetListId,
66
- });
67
- },
68
- [registeredLists]
69
- );
70
-
71
76
  const handleDragStart = React.useCallback(
72
77
  (event: DragStartEvent) => {
73
78
  // Capture the activator event for position calculations
@@ -79,12 +84,16 @@ export function SharedDragProvider({
79
84
 
80
85
  if (itemIndex >= 0) {
81
86
  const dragItem: DragItem = {
82
- id: event.active.id,
87
+ itemId: event.active.id,
83
88
  listId,
84
89
  index: itemIndex,
85
90
  };
86
91
 
87
- setActiveItem(dragItem);
92
+ setDragState({
93
+ source: dragItem,
94
+ target: null,
95
+ indicator: null,
96
+ });
88
97
  break;
89
98
  }
90
99
  }
@@ -92,139 +101,113 @@ export function SharedDragProvider({
92
101
  [registeredLists]
93
102
  );
94
103
 
95
- const handleDragMove = React.useCallback((event: DragMoveEvent) => {
96
- setDelta({ x: event.delta.x, y: event.delta.y });
97
- }, []);
104
+ const handleDragMove = React.useCallback(
105
+ (event: DragMoveEvent) => {
106
+ const { active, over } = event;
107
+
108
+ if (!activatorEventRef.current || !dragState.source) return;
109
+
110
+ const dropTarget = findDropTarget({
111
+ activationPoint: {
112
+ x: activatorEventRef.current.clientX,
113
+ y: activatorEventRef.current.clientY,
114
+ },
115
+ delta: { x: event.delta.x, y: event.delta.y },
116
+ over,
117
+ active,
118
+ source: dragState.source,
119
+ registeredLists,
120
+ });
121
+
122
+ setDragState({
123
+ source: dragState.source,
124
+ target: dropTarget?.target ?? null,
125
+ indicator: dropTarget?.indicator ?? null,
126
+ });
127
+ },
128
+ [registeredLists, dragState.source]
129
+ );
130
+
131
+ const stableOnMoveItem = useStableCallback(onMoveItem);
98
132
 
99
133
  const handleDragEnd = React.useCallback(
100
134
  (event: DragEndEvent) => {
101
135
  const { active, over } = event;
102
- const activatorEventPoint = activatorEventRef.current
103
- ? {
104
- x: activatorEventRef.current.clientX,
105
- y: activatorEventRef.current.clientY,
106
- }
107
- : null;
108
136
 
109
- setActiveItem(null);
110
- activatorEventRef.current = null;
111
-
112
- if (!activeItem || !over || active.id === over.id) {
113
- return;
114
- }
137
+ if (!activatorEventRef.current || !dragState.source) return;
138
+
139
+ const dropTarget = findDropTarget({
140
+ activationPoint: {
141
+ x: activatorEventRef.current.clientX,
142
+ y: activatorEventRef.current.clientY,
143
+ },
144
+ delta: { x: event.delta.x, y: event.delta.y },
145
+ over,
146
+ active,
147
+ source: dragState.source,
148
+ registeredLists,
149
+ });
115
150
 
116
- // Find target list and item
117
- let targetListId: string | null = null;
118
- let targetIndex: number = -1;
151
+ setDragState({
152
+ source: null,
153
+ target: null,
154
+ indicator: null,
155
+ });
156
+ activatorEventRef.current = null;
119
157
 
120
- for (const [listId, list] of registeredLists) {
121
- const itemIndex = list.keys.findIndex((key) => key === over.id);
158
+ if (!dropTarget) return;
122
159
 
123
- if (itemIndex >= 0) {
124
- targetListId = listId;
125
- targetIndex = itemIndex;
126
- break;
127
- }
160
+ const sourceList = registeredLists.get(dropTarget.source.listId);
161
+ const targetList = registeredLists.get(dropTarget.target.listId);
128
162
 
129
- // Also check if dropping on the list container itself
130
- if (over.id === listId) {
131
- targetListId = listId;
132
- targetIndex = list.keys.length; // Append to end
133
- break;
134
- }
135
- }
163
+ if (!sourceList || !targetList) return;
136
164
 
137
- if (!targetListId || !acceptsListDrop(activeItem.listId, targetListId)) {
138
- setActiveItem(null);
139
- activatorEventRef.current = null;
140
- return;
141
- }
165
+ const parameters: MoveDragItemParameters = {
166
+ sourceIndex: dropTarget.source.index,
167
+ targetIndex: dropTarget.target.index,
168
+ position: dropTarget.indicator,
169
+ sourceListId: dropTarget.source.listId,
170
+ targetListId: dropTarget.target.listId,
171
+ };
142
172
 
143
- const targetList = registeredLists.get(targetListId);
144
- const sourceList = registeredLists.get(activeItem.listId);
173
+ // Both lists have the opportunity to handle the move
174
+ sourceList.onMoveItem(parameters);
145
175
 
146
- if (!targetList || !sourceList) {
147
- return;
176
+ if (dropTarget.target.listId !== dropTarget.source.listId) {
177
+ targetList.onMoveItem(parameters);
148
178
  }
149
179
 
150
- // Handle drops into items within the list
151
- if (targetIndex < targetList.keys.length && activatorEventPoint) {
152
- // Calculate absolute cursor position
153
- const eventX = activatorEventPoint.x;
154
- const eventY = activatorEventPoint.y;
155
- const currentX = eventX + delta.x;
156
- const currentY = eventY + delta.y;
157
- const absolutePosition = { x: currentX, y: currentY };
158
-
159
- const indicator = targetList.getDropIndicator({
160
- absolutePosition,
161
- active,
162
- over,
163
- sourceListId: activeItem.listId,
164
- targetListId,
165
- });
166
-
167
- if (!indicator) return;
168
-
169
- // Notify the source list to handle the move
170
- sourceList.onMoveItem(
171
- activeItem.index,
172
- targetIndex,
173
- indicator,
174
- activeItem.listId,
175
- targetListId
176
- );
177
- }
178
- // Handle drops into empty lists or at the end of lists
179
- else if (targetIndex >= targetList.keys.length) {
180
- // For empty lists or drops at the end, we don't need detailed position validation
181
- // Just check if the drop is acceptable with a "below" position
182
- const canDrop = targetList.acceptsDrop(
183
- activeItem.index,
184
- Math.max(0, targetList.keys.length - 1), // Use last valid index or 0 for empty lists
185
- "below",
186
- activeItem.listId,
187
- targetListId
188
- );
189
-
190
- if (canDrop) {
191
- sourceList.onMoveItem(
192
- activeItem.index,
193
- targetIndex,
194
- "below",
195
- activeItem.listId,
196
- targetListId
197
- );
198
- }
199
- }
180
+ stableOnMoveItem?.(parameters);
200
181
  },
201
- [activeItem, registeredLists, acceptsListDrop, delta]
182
+ [registeredLists, dragState.source, stableOnMoveItem]
202
183
  );
203
184
 
204
185
  const contextValue: DragRegistrationContextType = React.useMemo(
205
186
  () => ({
206
- activeItem,
207
- delta,
208
187
  registerList,
209
188
  unregisterList,
210
189
  }),
211
- [activeItem, delta, registerList, unregisterList]
190
+ [registerList, unregisterList]
212
191
  );
213
192
 
214
- const activeList = activeItem ? registeredLists.get(activeItem.listId) : null;
193
+ const activeList = dragState.source
194
+ ? registeredLists.get(dragState.source.listId)
195
+ : null;
215
196
  const activeDragContextValue: ActiveDragContextType = React.useMemo(
216
- () => ({ activeItem, delta }),
217
- [activeItem, delta]
197
+ () => dragState,
198
+ [dragState]
218
199
  );
219
200
 
220
201
  const collisionDetection = useMemo(() => {
221
- return getItemFirstCollisionDetection(Array.from(registeredLists.keys()));
202
+ return getItemFirstCollisionDetection(registeredLists);
222
203
  }, [registeredLists]);
223
204
 
224
205
  return (
225
206
  <AnyDragContext.Provider value={true}>
226
- <contexts.dragRegistrationContext.Provider value={contextValue}>
227
- <contexts.activeDragContext.Provider value={activeDragContextValue}>
207
+ <sharedDragProps.dragRegistrationContext.Provider value={contextValue}>
208
+ <sharedDragProps.activeDragContext.Provider
209
+ value={activeDragContextValue}
210
+ >
228
211
  <DndContext
229
212
  sensors={sensors}
230
213
  collisionDetection={collisionDetection}
@@ -235,16 +218,16 @@ export function SharedDragProvider({
235
218
  {children}
236
219
  {mounted &&
237
220
  activeList?.renderOverlay &&
238
- activeItem &&
221
+ dragState.source &&
239
222
  createPortal(
240
223
  <DragOverlay dropAnimation={null}>
241
- {activeList.renderOverlay(activeItem.id)}
224
+ {activeList.renderOverlay(dragState.source.itemId)}
242
225
  </DragOverlay>,
243
226
  document.body
244
227
  )}
245
228
  </DndContext>
246
- </contexts.activeDragContext.Provider>
247
- </contexts.dragRegistrationContext.Provider>
229
+ </sharedDragProps.activeDragContext.Provider>
230
+ </sharedDragProps.dragRegistrationContext.Provider>
248
231
  </AnyDragContext.Provider>
249
232
  );
250
233
  }
@@ -279,13 +262,13 @@ export function withDragProvider<T extends object>(
279
262
 
280
263
  // Custom collision detection that prioritizes items over containers
281
264
  export const getItemFirstCollisionDetection =
282
- (registeredListIds: string[]): CollisionDetection =>
265
+ (registeredLists: Map<string, RegisteredList>): CollisionDetection =>
283
266
  (parameters) => {
284
267
  const pointerCollisions = pointerWithin(parameters);
285
268
 
286
269
  if (pointerCollisions.length > 0) {
287
270
  const itemCollisions = pointerCollisions.filter((collision) => {
288
- for (const listId of registeredListIds) {
271
+ for (const listId of registeredLists.keys()) {
289
272
  if (collision.id === listId) {
290
273
  return false;
291
274
  }
@@ -299,9 +282,149 @@ export const getItemFirstCollisionDetection =
299
282
  }
300
283
  }
301
284
 
302
- // Fall back to pointer-based detection (includes containers)
303
- // This handles empty lists and edge cases
304
- return pointerCollisions.length > 0
305
- ? pointerCollisions
306
- : closestCenter(parameters);
285
+ const listCollision = pointerCollisions.at(0);
286
+ const nearestCenters = closestCenter(parameters);
287
+
288
+ // Handle a pointer between items by checking if the pointer is within
289
+ // a window of two items.
290
+ if (parameters.pointerCoordinates && listCollision) {
291
+ const list = registeredLists.get(listCollision.id as string);
292
+
293
+ if (list) {
294
+ const pairs = windowsOf(list.keys, 2);
295
+
296
+ for (const pair of pairs) {
297
+ const clientRects = pair.map(
298
+ (key) => parameters.droppableRects.get(key)!
299
+ );
300
+
301
+ const merged = unionRects(
302
+ ...clientRects.map((rect) => ({
303
+ x: rect.left,
304
+ y: rect.top,
305
+ width: rect.width,
306
+ height: rect.height,
307
+ }))
308
+ );
309
+
310
+ if (rectContainsPoint(merged, parameters.pointerCoordinates)) {
311
+ return nearestCenters.filter((center) =>
312
+ pair.some((id) => id === center.id)
313
+ );
314
+ }
315
+ }
316
+ }
317
+ }
318
+
319
+ // This handles empty lists
320
+ return pointerCollisions;
307
321
  };
322
+
323
+ function findDropTarget({
324
+ activationPoint,
325
+ delta,
326
+ over,
327
+ active,
328
+ source,
329
+ registeredLists,
330
+ }: {
331
+ activationPoint: Point;
332
+ delta: Point;
333
+ over: Over | null;
334
+ active: Active;
335
+ source: DragItem;
336
+ registeredLists: Map<string, RegisteredList>;
337
+ }):
338
+ | {
339
+ source: DragItem;
340
+ target: DragItem;
341
+ indicator: RelativeDropPosition;
342
+ }
343
+ | undefined {
344
+ if (!over || active.id === over.id) return;
345
+
346
+ // Find target list and item
347
+ let targetListId: string | null = null;
348
+ let targetIndex: number = -1;
349
+
350
+ for (const [listId, list] of registeredLists) {
351
+ const itemIndex = list.keys.findIndex((key) => key === over.id);
352
+
353
+ if (itemIndex >= 0) {
354
+ targetListId = listId;
355
+ targetIndex = itemIndex;
356
+ break;
357
+ }
358
+
359
+ // Also check if dropping on the list container itself
360
+ if (over.id === listId) {
361
+ targetListId = listId;
362
+ targetIndex = list.keys.length; // Append to end
363
+ break;
364
+ }
365
+ }
366
+
367
+ const targetList =
368
+ targetListId !== null ? registeredLists.get(targetListId) : null;
369
+ const sourceList = registeredLists.get(source.listId);
370
+
371
+ if (
372
+ !targetList ||
373
+ !sourceList ||
374
+ !targetList.acceptsFromList({
375
+ sourceListId: sourceList.id,
376
+ targetListId: targetList.id,
377
+ })
378
+ ) {
379
+ return;
380
+ }
381
+
382
+ // Dropping on the list container itself
383
+ if (over.id === targetList.id) {
384
+ const canDrop = targetList.acceptsDrop({
385
+ sourceIndex: source.index,
386
+ targetIndex: Math.max(0, targetList.keys.length - 1), // Use last valid index or 0 for empty lists
387
+ position: "below",
388
+ sourceListId: sourceList.id,
389
+ targetListId: targetList.id,
390
+ });
391
+
392
+ if (canDrop) {
393
+ return {
394
+ source,
395
+ target: {
396
+ itemId: over.id,
397
+ listId: targetList.id,
398
+ index: targetIndex,
399
+ },
400
+ indicator: "below",
401
+ };
402
+ }
403
+ }
404
+
405
+ const eventX = activationPoint.x;
406
+ const eventY = activationPoint.y;
407
+ const currentX = eventX + delta.x;
408
+ const currentY = eventY + delta.y;
409
+ const absolutePosition = { x: currentX, y: currentY };
410
+
411
+ const indicator = targetList.getDropIndicator({
412
+ absolutePosition,
413
+ active,
414
+ over,
415
+ sourceListId: sourceList.id,
416
+ targetListId: targetList.id,
417
+ });
418
+
419
+ if (indicator) {
420
+ return {
421
+ source,
422
+ target: {
423
+ itemId: over.id,
424
+ listId: targetList.id,
425
+ index: targetIndex,
426
+ },
427
+ indicator,
428
+ };
429
+ }
430
+ }