Skip to content

Commit 64a30fc

Browse files
committed
improve DragList implementation
1 parent 93da50b commit 64a30fc

11 files changed

Lines changed: 1924 additions & 11 deletions

File tree

examples/src/pages/tests/table/dnd/dnd-reorder.page.tsx

Lines changed: 426 additions & 0 deletions
Large diffs are not rendered by default.

examples/src/pages/tests/table/dnd/dnd-source-target.page.tsx

Lines changed: 677 additions & 0 deletions
Large diffs are not rendered by default.

source/src/components/InfiniteTable/components/GroupingToolbar/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ export function GroupingToolbar<T = any>(props: GroupingToolbarProps) {
283283
dragListId={GROUPING_TOOLBAR_DRAG_LIST_ID}
284284
acceptDropsFrom={['header', GROUPING_TOOLBAR_DRAG_LIST_ID]}
285285
onDrop={onDrop}
286+
dragStrategy="inline"
286287
onAcceptDrop={onAcceptDrop}
287288
shouldAcceptDrop={shouldAcceptDrop}
288289
>

source/src/components/InfiniteTable/components/InfiniteTableHeader/InfiniteTableHeader.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ function InfiniteTableInternalHeaderFn<T>(
227227
<DragList
228228
orientation="horizontal"
229229
dragListId="header"
230+
dragStrategy="inline"
230231
onDrop={emptyFn}
231232
updatePosition={emptyFn}
232233
>
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
import { PointCoords } from '../../../../utils/pageGeometry/Point';
2+
3+
export type AutoScrollerConfig = {
4+
// what percentage of container size defines the start of the scroll zone
5+
// e.g. 0.25 = scrolling starts when within 25% of the edge
6+
startFromPercentage: number;
7+
// what percentage of container size defines where max scroll speed kicks in
8+
maxScrollAtPercentage: number;
9+
// max pixels scrolled per animation frame
10+
maxPixelScroll: number;
11+
// easing function applied to scroll speed based on proximity
12+
ease: (percentage: number) => number;
13+
// time dampening: prevents instant scrolling when grabbing near an edge
14+
durationDampening: {
15+
stopDampeningAt: number;
16+
accelerateAt: number;
17+
};
18+
};
19+
20+
export const defaultAutoScrollerConfig: AutoScrollerConfig = {
21+
startFromPercentage: 0.25,
22+
maxScrollAtPercentage: 0.05,
23+
maxPixelScroll: 28,
24+
ease: (pct: number) => pct ** 2,
25+
durationDampening: {
26+
stopDampeningAt: 1200,
27+
accelerateAt: 360,
28+
},
29+
};
30+
31+
function isScrollable(
32+
element: HTMLElement,
33+
orientation: 'horizontal' | 'vertical',
34+
): boolean {
35+
const style = getComputedStyle(element);
36+
const overflowProp =
37+
orientation === 'vertical' ? style.overflowY : style.overflowX;
38+
39+
if (overflowProp !== 'auto' && overflowProp !== 'scroll') {
40+
return false;
41+
}
42+
43+
return orientation === 'vertical'
44+
? element.scrollHeight > element.clientHeight
45+
: element.scrollWidth > element.clientWidth;
46+
}
47+
48+
function findScrollableAncestor(
49+
element: HTMLElement,
50+
orientation: 'horizontal' | 'vertical',
51+
): HTMLElement | null {
52+
let current: HTMLElement | null = element;
53+
54+
while (current) {
55+
if (current === document.documentElement || current === document.body) {
56+
break;
57+
}
58+
59+
if (isScrollable(current, orientation)) {
60+
return current;
61+
}
62+
63+
current = current.parentElement;
64+
}
65+
66+
return null;
67+
}
68+
69+
interface DistanceThresholds {
70+
startScrollingFrom: number;
71+
maxScrollValueAt: number;
72+
}
73+
74+
function getThresholds(
75+
containerSize: number,
76+
config: AutoScrollerConfig,
77+
): DistanceThresholds {
78+
return {
79+
startScrollingFrom: containerSize * config.startFromPercentage,
80+
maxScrollValueAt: containerSize * config.maxScrollAtPercentage,
81+
};
82+
}
83+
84+
const MIN_SCROLL = 1;
85+
86+
function getValueFromDistance(
87+
distanceToEdge: number,
88+
thresholds: DistanceThresholds,
89+
config: AutoScrollerConfig,
90+
): number {
91+
if (distanceToEdge > thresholds.startScrollingFrom) {
92+
return 0;
93+
}
94+
95+
if (distanceToEdge <= thresholds.maxScrollValueAt) {
96+
return config.maxPixelScroll;
97+
}
98+
99+
if (distanceToEdge === thresholds.startScrollingFrom) {
100+
return MIN_SCROLL;
101+
}
102+
103+
const percentFromMax =
104+
(distanceToEdge - thresholds.maxScrollValueAt) /
105+
(thresholds.startScrollingFrom - thresholds.maxScrollValueAt);
106+
107+
const percentFromStart = 1 - percentFromMax;
108+
109+
return Math.ceil(config.maxPixelScroll * config.ease(percentFromStart));
110+
}
111+
112+
function dampenByTime(
113+
proposedScroll: number,
114+
dragStartTime: number,
115+
config: AutoScrollerConfig,
116+
): number {
117+
const { accelerateAt, stopDampeningAt } = config.durationDampening;
118+
const runTime = Date.now() - dragStartTime;
119+
120+
if (runTime >= stopDampeningAt) {
121+
return proposedScroll;
122+
}
123+
124+
if (runTime < accelerateAt) {
125+
return MIN_SCROLL;
126+
}
127+
128+
const pct = (runTime - accelerateAt) / (stopDampeningAt - accelerateAt);
129+
130+
return Math.ceil(proposedScroll * config.ease(pct));
131+
}
132+
133+
function getScrollForAxis(
134+
point: number,
135+
containerStart: number,
136+
containerEnd: number,
137+
containerSize: number,
138+
config: AutoScrollerConfig,
139+
dragStartTime: number,
140+
shouldUseDampening: boolean,
141+
): number {
142+
// no auto-scroll when the pointer is outside the container
143+
if (point < containerStart || point > containerEnd) {
144+
return 0;
145+
}
146+
147+
const thresholds = getThresholds(containerSize, config);
148+
const distToStart = point - containerStart;
149+
const distToEnd = containerEnd - point;
150+
const closerToEnd = distToEnd < distToStart;
151+
152+
let value: number;
153+
if (closerToEnd) {
154+
value = getValueFromDistance(distToEnd, thresholds, config);
155+
} else {
156+
value = -getValueFromDistance(distToStart, thresholds, config);
157+
}
158+
159+
if (value === 0) {
160+
return 0;
161+
}
162+
163+
if (shouldUseDampening) {
164+
const sign = value > 0 ? 1 : -1;
165+
return sign * dampenByTime(Math.abs(value), dragStartTime, config);
166+
}
167+
168+
return value;
169+
}
170+
171+
export type AutoScrollerOnScroll = (scrollDelta: PointCoords) => void;
172+
173+
export class AutoScroller {
174+
private scrollContainer: HTMLElement | null = null;
175+
private orientation: 'horizontal' | 'vertical';
176+
private config: AutoScrollerConfig;
177+
private rafId: number | null = null;
178+
private lastPointer: PointCoords = { left: 0, top: 0 };
179+
private dragStartTime: number = 0;
180+
private shouldUseDampening: boolean = false;
181+
private onScroll: AutoScrollerOnScroll;
182+
private active: boolean = false;
183+
184+
// captured at drag start before transforms inflate scrollHeight
185+
private maxScrollTop: number = 0;
186+
private maxScrollLeft: number = 0;
187+
188+
constructor(options: {
189+
orientation: 'horizontal' | 'vertical';
190+
onScroll: AutoScrollerOnScroll;
191+
config?: Partial<AutoScrollerConfig>;
192+
}) {
193+
this.orientation = options.orientation;
194+
this.onScroll = options.onScroll;
195+
this.config = { ...defaultAutoScrollerConfig, ...options.config };
196+
}
197+
198+
start(listElement: HTMLElement) {
199+
this.scrollContainer = findScrollableAncestor(
200+
listElement,
201+
this.orientation,
202+
);
203+
204+
if (this.scrollContainer) {
205+
// Snapshot the real content extent before CSS transforms inflate it.
206+
// During drag, transforms on the active item can extend scrollHeight,
207+
// creating a feedback loop. Clamping to this snapshot prevents that.
208+
this.maxScrollTop =
209+
this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight;
210+
this.maxScrollLeft =
211+
this.scrollContainer.scrollWidth - this.scrollContainer.clientWidth;
212+
}
213+
214+
this.dragStartTime = Date.now();
215+
this.shouldUseDampening = true;
216+
this.active = true;
217+
}
218+
219+
getScrollContainer(): HTMLElement | null {
220+
return this.scrollContainer;
221+
}
222+
223+
updatePointer(point: PointCoords) {
224+
this.lastPointer = point;
225+
226+
if (!this.rafId && this.active) {
227+
this.scheduleScroll();
228+
}
229+
}
230+
231+
private scheduleScroll() {
232+
this.rafId = requestAnimationFrame(() => {
233+
this.rafId = null;
234+
235+
if (!this.active || !this.scrollContainer) {
236+
return;
237+
}
238+
239+
const delta = this.computeScroll();
240+
if (delta.top === 0 && delta.left === 0) {
241+
return;
242+
}
243+
244+
const scrollBefore = {
245+
top: this.scrollContainer.scrollTop,
246+
left: this.scrollContainer.scrollLeft,
247+
};
248+
249+
// Clamp so we never scroll past the real content extent
250+
// captured at drag start (before transforms inflated scrollHeight).
251+
const clampedTop = Math.max(
252+
0,
253+
Math.min(this.maxScrollTop, scrollBefore.top + delta.top),
254+
);
255+
const clampedLeft = Math.max(
256+
0,
257+
Math.min(this.maxScrollLeft, scrollBefore.left + delta.left),
258+
);
259+
260+
this.scrollContainer.scrollTop = clampedTop;
261+
this.scrollContainer.scrollLeft = clampedLeft;
262+
263+
const actualDelta = {
264+
top: this.scrollContainer.scrollTop - scrollBefore.top,
265+
left: this.scrollContainer.scrollLeft - scrollBefore.left,
266+
};
267+
268+
const didScroll = actualDelta.top !== 0 || actualDelta.left !== 0;
269+
270+
if (didScroll) {
271+
this.onScroll(actualDelta);
272+
}
273+
274+
// Only continue the loop if scrolling actually happened.
275+
// When the container hits its scroll limit the browser clamps scrollTop,
276+
// actualDelta becomes 0, and we stop. The next updatePointer() call
277+
// will restart the loop if the user moves to a scrollable direction.
278+
if (this.active && didScroll) {
279+
this.scheduleScroll();
280+
}
281+
});
282+
}
283+
284+
private computeScroll(): PointCoords {
285+
if (!this.scrollContainer) {
286+
return { top: 0, left: 0 };
287+
}
288+
289+
const rect = this.scrollContainer.getBoundingClientRect();
290+
291+
// Only scroll when the pointer is within the container on the cross axis.
292+
// Without this, dragging an item off to the side of a list still triggers
293+
// scrolling because the scroll-axis position is near an edge.
294+
if (this.orientation === 'vertical') {
295+
if (
296+
this.lastPointer.left < rect.left ||
297+
this.lastPointer.left > rect.right
298+
) {
299+
return { top: 0, left: 0 };
300+
}
301+
} else {
302+
if (
303+
this.lastPointer.top < rect.top ||
304+
this.lastPointer.top > rect.bottom
305+
) {
306+
return { top: 0, left: 0 };
307+
}
308+
}
309+
310+
let scrollTop = 0;
311+
let scrollLeft = 0;
312+
313+
if (this.orientation === 'vertical') {
314+
scrollTop = getScrollForAxis(
315+
this.lastPointer.top,
316+
rect.top,
317+
rect.bottom,
318+
rect.height,
319+
this.config,
320+
this.dragStartTime,
321+
this.shouldUseDampening,
322+
);
323+
} else {
324+
scrollLeft = getScrollForAxis(
325+
this.lastPointer.left,
326+
rect.left,
327+
rect.right,
328+
rect.width,
329+
this.config,
330+
this.dragStartTime,
331+
this.shouldUseDampening,
332+
);
333+
}
334+
335+
return { top: scrollTop, left: scrollLeft };
336+
}
337+
338+
stop() {
339+
this.active = false;
340+
if (this.rafId != null) {
341+
cancelAnimationFrame(this.rafId);
342+
this.rafId = null;
343+
}
344+
this.scrollContainer = null;
345+
}
346+
}

source/src/components/InfiniteTable/components/draggable/DragInteractionTarget.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type DragInteractionTargetData = {
1616
acceptDropsFrom?: string[];
1717
shouldAcceptDrop?: (event: DragInteractionTargetMoveEvent) => boolean;
1818
initial: boolean;
19+
preserveDragSpace?: boolean;
1920
};
2021
export type DraggableItem = {
2122
id: string;
@@ -141,6 +142,22 @@ export class DragInteractionTarget extends EventEmitter<DragInteractionTargetEve
141142
return result;
142143
}
143144

145+
/**
146+
* Adjusts breakpoints and listRectangle to compensate for container scroll.
147+
* When the container scrolls by `scrollDelta`, items shift in the viewport
148+
* but the pointer (in the source-adjusted coordinate space) doesn't change.
149+
* Shifting breakpoints keeps the drop index calculation correct.
150+
*/
151+
adjustForScroll(scrollDelta: number) {
152+
this.breakpoints = this.breakpoints.map((bp) => bp - scrollDelta);
153+
154+
if (this.data.orientation === 'vertical') {
155+
this.data.listRectangle.shift({ top: -scrollDelta, left: 0 });
156+
} else {
157+
this.data.listRectangle.shift({ top: 0, left: -scrollDelta });
158+
}
159+
}
160+
144161
move(params: DragInteractionTargetMoveEvent) {
145162
this.emit('move', params);
146163
}

0 commit comments

Comments
 (0)