Skip to content

Commit d1e58da

Browse files
authored
refactor(design): remove react-grid-layout for DraggableList (#6632)
1 parent 747f71b commit d1e58da

7 files changed

Lines changed: 349 additions & 274 deletions

File tree

packages/design/package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,21 +75,18 @@
7575
"@radix-ui/react-separator": "^1.1.8",
7676
"@radix-ui/react-slot": "^1.2.4",
7777
"@univerjs/icons": "^1.1.1",
78-
"@univerjs/themes": "workspace:*",
7978
"class-variance-authority": "^0.7.1",
8079
"clsx": "^2.1.1",
8180
"dayjs": "^1.11.19",
8281
"rc-dropdown": "^4.2.1",
8382
"rc-menu": "^9.16.0",
84-
"react-grid-layout": "^1.5.1",
8583
"react-transition-group": "^4.4.5",
8684
"sonner": "^2.0.7",
8785
"tailwind-merge": "2.6.0"
8886
},
8987
"devDependencies": {
9088
"@testing-library/jest-dom": "6.9.1",
9189
"@testing-library/react": "^16.3.2",
92-
"@types/react-grid-layout": "^1.3.6",
9390
"@types/react-transition-group": "^4.4.12",
9491
"@univerjs-infra/shared": "workspace:*",
9592
"@univerjs/core": "workspace:*",

packages/design/src/components/draggable-list/DraggableList.tsx

Lines changed: 251 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,61 +14,268 @@
1414
* limitations under the License.
1515
*/
1616

17-
import type { ReactNode } from 'react';
18-
import type { Layout, ReactGridLayoutProps } from 'react-grid-layout';
19-
import { useMemo } from 'react';
20-
import RGL, { WidthProvider } from 'react-grid-layout';
21-
import 'react-grid-layout/css/styles.css';
17+
import type { CSSProperties, HTMLAttributes, PointerEvent, ReactNode } from 'react';
18+
import { useEffect, useMemo, useRef, useState } from 'react';
19+
import { createPortal } from 'react-dom';
20+
import { clsx } from '../../helper/clsx';
2221

23-
export const ReactGridLayout = WidthProvider(RGL);
22+
interface IDragPosition {
23+
y: number;
24+
}
25+
26+
interface IDraggableListDragCallbacks {
27+
onDragStart?: (layout: unknown, from: IDragPosition) => void;
28+
onDragStop?: (layout: unknown, from: IDragPosition, to: IDragPosition) => void;
29+
}
2430

25-
export interface IDraggableListProps<T> extends Omit<ReactGridLayoutProps, 'layout' | 'onLayoutChange' | 'cols' | 'isResizable'> {
31+
export interface IDraggableListProps<T> extends Omit<HTMLAttributes<HTMLDivElement>, 'onDragStart'>, IDraggableListDragCallbacks {
2632
list: T[];
2733
onListChange: (list: T[]) => void;
2834
idKey: keyof T;
2935
itemRender: (item: T, index: number) => ReactNode;
36+
draggableHandle?: string;
37+
rowHeight?: number;
38+
margin?: [number, number];
39+
}
40+
41+
function moveItemByIndex<T>(list: T[], fromIndex: number, toIndex: number) {
42+
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= list.length || toIndex >= list.length) {
43+
return list;
44+
}
45+
const next = [...list];
46+
const [item] = next.splice(fromIndex, 1);
47+
next.splice(toIndex, 0, item);
48+
return next;
3049
}
3150

3251
export function DraggableList<T = any>(props: IDraggableListProps<T>) {
33-
const { list, onListChange, idKey, itemRender, ...gridProps } = props;
34-
35-
const listMap = useMemo(() => {
36-
const listMap = new Map<unknown, T>();
37-
list.forEach((item: T) => {
38-
const key = item[idKey];
39-
listMap.set(key, item);
40-
});
41-
return listMap;
42-
}, [idKey, list]);
43-
44-
const layouts: Layout[] = useMemo(() => {
45-
return list.map((item, index) => ({
46-
i: item[idKey] as string,
47-
w: 12,
48-
h: 1,
49-
x: 0,
50-
y: index,
51-
col: 12,
52-
}));
53-
}, [idKey, list]);
52+
const {
53+
list,
54+
onListChange,
55+
idKey,
56+
itemRender,
57+
className,
58+
style,
59+
draggableHandle,
60+
rowHeight,
61+
margin = [0, 0],
62+
onDragStart,
63+
onDragStop,
64+
...restProps
65+
} = props;
66+
67+
const [displayList, setDisplayList] = useState(list);
68+
const displayListRef = useRef(list);
69+
const [draggingId, setDraggingId] = useState<string | null>(null);
70+
const [dragOverId, setDragOverId] = useState<string | null>(null);
71+
const [ghostMarkup, setGhostMarkup] = useState<string | null>(null);
72+
const [ghostPosition, setGhostPosition] = useState<{ x: number; y: number } | null>(null);
73+
const [canUsePortal, setCanUsePortal] = useState(false);
74+
const pointerIdRef = useRef<number | null>(null);
75+
const dragPointerOffsetRef = useRef({ x: 0, y: 0 });
76+
const dragItemSizeRef = useRef({ width: 0, height: 0 });
77+
78+
const pressedHandleIdRef = useRef<string | null>(null);
79+
const dragSourceIdRef = useRef<string | null>(null);
80+
const dragStartIndexRef = useRef(-1);
81+
82+
useEffect(() => {
83+
if (!draggingId) {
84+
setDisplayList(list);
85+
displayListRef.current = list;
86+
}
87+
}, [draggingId, list]);
88+
89+
useEffect(() => {
90+
setCanUsePortal(typeof document !== 'undefined');
91+
}, []);
92+
93+
useEffect(() => {
94+
if (!draggingId) {
95+
return;
96+
}
97+
98+
const handlePointerMove = (evt: PointerEvent | globalThis.PointerEvent) => {
99+
const sourceId = dragSourceIdRef.current;
100+
if (!sourceId || pointerIdRef.current !== evt.pointerId) {
101+
return;
102+
}
103+
setGhostPosition({
104+
x: evt.clientX - dragPointerOffsetRef.current.x,
105+
y: evt.clientY - dragPointerOffsetRef.current.y,
106+
});
107+
108+
const element = document.elementFromPoint(evt.clientX, evt.clientY);
109+
const itemElement = element?.closest('[data-draggable-list-item-id]') as HTMLElement | null;
110+
const targetId = itemElement?.dataset.draggableListItemId;
111+
if (!targetId || targetId === sourceId) {
112+
return;
113+
}
114+
setDragOverId(targetId);
115+
116+
setDisplayList((prev) => {
117+
const fromIndex = prev.findIndex((it) => getItemId(it) === sourceId);
118+
const toIndex = prev.findIndex((it) => getItemId(it) === targetId);
119+
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
120+
return prev;
121+
}
122+
const next = moveItemByIndex(prev, fromIndex, toIndex);
123+
displayListRef.current = next;
124+
return next;
125+
});
126+
};
127+
128+
const handlePointerUp = (evt: globalThis.PointerEvent) => {
129+
if (pointerIdRef.current !== evt.pointerId) {
130+
return;
131+
}
132+
const sourceId = dragSourceIdRef.current;
133+
const startIndex = dragStartIndexRef.current;
134+
const currentList = displayListRef.current;
135+
if (sourceId) {
136+
const finalIndex = currentList.findIndex((it) => getItemId(it) === sourceId);
137+
onDragStop?.(undefined, { y: startIndex }, { y: finalIndex });
138+
if (startIndex >= 0) {
139+
onListChange([...currentList]);
140+
}
141+
}
142+
143+
pointerIdRef.current = null;
144+
pressedHandleIdRef.current = null;
145+
dragSourceIdRef.current = null;
146+
dragStartIndexRef.current = -1;
147+
setDragOverId(null);
148+
setGhostMarkup(null);
149+
setGhostPosition(null);
150+
setDraggingId(null);
151+
};
152+
153+
window.addEventListener('pointermove', handlePointerMove as any);
154+
window.addEventListener('pointerup', handlePointerUp);
155+
window.addEventListener('pointercancel', handlePointerUp);
156+
157+
return () => {
158+
window.removeEventListener('pointermove', handlePointerMove as any);
159+
window.removeEventListener('pointerup', handlePointerUp);
160+
window.removeEventListener('pointercancel', handlePointerUp);
161+
};
162+
}, [draggingId, onDragStop, onListChange]);
163+
164+
const gapStyle: CSSProperties = useMemo(() => {
165+
const [horizontal, vertical] = margin;
166+
return {
167+
rowGap: `${vertical}px`,
168+
paddingLeft: horizontal ? `${horizontal}px` : undefined,
169+
paddingRight: horizontal ? `${horizontal}px` : undefined,
170+
...style,
171+
};
172+
}, [margin, style]);
173+
174+
const getItemId = (item: T) => String(item[idKey]);
175+
const draggingItem = draggingId ? displayList.find((item) => getItemId(item) === draggingId) : null;
54176

55177
return (
56-
<ReactGridLayout
57-
{...gridProps}
58-
cols={12}
59-
preventCollision={false}
60-
isResizable={false}
61-
isDraggable
62-
onLayoutChange={(layout) => {
63-
const newList = layout.sort((prev, aft) => prev.y - aft.y).map((item) => listMap.get(item.i)!);
64-
onListChange(newList);
65-
}}
66-
>
67-
{layouts.map((item, index) => (
68-
<div key={item.i} data-grid={item}>
69-
{itemRender(listMap.get(item.i)!, index)}
178+
<>
179+
<div
180+
{...restProps}
181+
className={clsx('univer-flex univer-flex-col', draggingId && 'univer-cursor-grabbing univer-select-none', className)}
182+
style={gapStyle}
183+
>
184+
{displayList.map((item, index) => {
185+
const itemId = getItemId(item);
186+
const isDraggingItem = draggingId === itemId;
187+
const isDragOverItem = dragOverId === itemId && !isDraggingItem;
188+
189+
return (
190+
<div
191+
key={itemId}
192+
data-draggable-list-item-id={itemId}
193+
className={clsx(
194+
'univer-relative univer-transition-all univer-duration-150',
195+
isDraggingItem && 'univer-opacity-0',
196+
isDragOverItem && `
197+
univer-bg-primary-50/60
198+
dark:!univer-bg-primary-900/20
199+
univer-rounded univer-border univer-border-primary-200
200+
dark:!univer-border-primary-700
201+
`
202+
)}
203+
onPointerDownCapture={(e: PointerEvent<HTMLDivElement>) => {
204+
if (pointerIdRef.current !== null) {
205+
return;
206+
}
207+
if (!draggableHandle) {
208+
pressedHandleIdRef.current = itemId;
209+
} else {
210+
const target = e.target as HTMLElement;
211+
const isMatched = !!target.closest(draggableHandle);
212+
pressedHandleIdRef.current = isMatched ? itemId : null;
213+
}
214+
if (pressedHandleIdRef.current !== itemId) {
215+
return;
216+
}
217+
218+
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
219+
dragPointerOffsetRef.current = {
220+
x: e.clientX - rect.left,
221+
y: e.clientY - rect.top,
222+
};
223+
dragItemSizeRef.current = {
224+
width: rect.width,
225+
height: rect.height,
226+
};
227+
setGhostPosition({ x: rect.left, y: rect.top });
228+
setGhostMarkup((e.currentTarget as HTMLDivElement).innerHTML);
229+
230+
pointerIdRef.current = e.pointerId;
231+
dragSourceIdRef.current = itemId;
232+
dragStartIndexRef.current = index;
233+
setDraggingId(itemId);
234+
displayListRef.current = displayList;
235+
onDragStart?.(undefined, { y: index });
236+
e.preventDefault();
237+
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
238+
}}
239+
style={{
240+
...(rowHeight ? { minHeight: `${rowHeight}px` } : undefined),
241+
cursor: draggingId ? 'grabbing' : undefined,
242+
}}
243+
>
244+
{isDraggingItem && (
245+
<div
246+
className="
247+
univer-bg-primary-50/50 univer-absolute univer-inset-0 univer-rounded
248+
univer-border univer-border-dashed univer-border-primary-300
249+
"
250+
/>
251+
)}
252+
{itemRender(item, index)}
253+
</div>
254+
);
255+
})}
256+
</div>
257+
{canUsePortal && draggingItem && ghostPosition && createPortal((
258+
<div
259+
className={clsx(`
260+
univer-pointer-events-none univer-fixed univer-rounded-md univer-border univer-border-gray-200
261+
univer-bg-white univer-shadow-lg
262+
dark:!univer-border-gray-700 dark:!univer-bg-gray-800
263+
`)}
264+
style={{
265+
zIndex: 2147483647,
266+
left: `${ghostPosition.x}px`,
267+
top: `${ghostPosition.y}px`,
268+
width: `${dragItemSizeRef.current.width}px`,
269+
height: `${dragItemSizeRef.current.height}px`,
270+
opacity: 0.95,
271+
}}
272+
>
273+
{ghostMarkup
274+
? <div dangerouslySetInnerHTML={{ __html: ghostMarkup }} />
275+
: itemRender(draggingItem, displayList.findIndex((item) => getItemId(item) === getItemId(draggingItem)))}
70276
</div>
71-
))}
72-
</ReactGridLayout>
277+
), document.body
278+
)}
279+
</>
73280
);
74281
}

packages/design/src/components/draggable-list/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,5 @@
1414
* limitations under the License.
1515
*/
1616

17-
export { DraggableList, ReactGridLayout } from './DraggableList';
17+
export { DraggableList } from './DraggableList';
1818
export type { IDraggableListProps } from './DraggableList';

packages/design/src/index.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export { Confirm, type IConfirmProps } from './components/confirm/Confirm';
3131
export { DatePicker } from './components/date-picker/DatePicker';
3232
export { DateRangePicker } from './components/date-range-picker';
3333
export { Dialog, type IDialogProps } from './components/dialog/Dialog';
34-
export { DraggableList, type IDraggableListProps, ReactGridLayout } from './components/draggable-list';
34+
export { DraggableList, type IDraggableListProps } from './components/draggable-list';
3535
export { DropdownMenu, type IDropdownMenuProps } from './components/dropdown-menu/DropdownMenu';
3636
export { Dropdown, type IDropdownProps } from './components/dropdown/Dropdown';
3737
export { FormDualColumnLayout, FormLayout, type IFormDualColumnLayoutProps, type IFormLayoutProps } from './components/form-layout';
@@ -82,6 +82,3 @@ export { clsx } from './helper/clsx';
8282
export { isBrowser } from './helper/is-browser';
8383
export { render, unmount } from './helper/react-dom';
8484
export { resizeObserverCtor } from './helper/resize-observer';
85-
86-
/** @deprecated Only for compatibility with versions before 0.7.0, will be removed in future versions */
87-
export { defaultTheme, greenTheme } from '@univerjs/themes';

0 commit comments

Comments
 (0)