Skip to content

Commit df6183c

Browse files
authored
Merge pull request #4385 from udecode/multiple-dnd
Drag and drop improvements
2 parents 9b4dbc2 + 31f02ab commit df6183c

File tree

22 files changed

+596
-118
lines changed

22 files changed

+596
-118
lines changed

.changeset/tender-horses-beg.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@platejs/selection': patch
3+
---
4+
5+
- Added `sort` and `collapseTableRows` options to `editor.blockSelection.getNodes()` method.
6+
7+
- Added `normalize` function to handle table selection logic in `useSelectionArea` hook for improved table row and table element selection behavior.
8+
- It is now possible to select the entire table (table), but the rows (tr) will only be selected if your selection box is within the table.

.changeset/three-singers-change.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@platejs/dnd': patch
3+
---
4+
5+
- Fixed an issue where drag and drop functionality would not work properly with multiple selected blocks.
6+
- Added logic to ensure only one drop position exists between any two nodes to prevent visual blinking during drag operations.

apps/www/src/registry/components/editor/plugins/block-selection-kit.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export const BlockSelectionKit = [
1313
return !getPluginTypes(editor, [
1414
KEYS.column,
1515
KEYS.codeLine,
16-
KEYS.table,
1716
KEYS.td,
1817
]).includes(element.type);
1918
},

apps/www/src/registry/examples/values/playground-value.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,8 @@ export const playgroundValue: Value = [
488488
type: 'tr',
489489
},
490490
],
491+
492+
colSizes: [160, 170, 200],
491493
type: 'table',
492494
},
493495
// Media Section

apps/www/src/registry/ui/block-draggable.tsx

Lines changed: 216 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@
22

33
import * as React from 'react';
44

5-
import { useDraggable, useDropLine } from '@platejs/dnd';
5+
import { DndPlugin, useDraggable, useDropLine } from '@platejs/dnd';
66
import { BlockSelectionPlugin } from '@platejs/selection/react';
77
import { GripVertical } from 'lucide-react';
8-
import { getPluginByType, isType, KEYS } from 'platejs';
8+
import { type TElement, getPluginByType, isType, KEYS } from 'platejs';
99
import {
10+
type PlateEditor,
1011
type PlateElementProps,
1112
type RenderNodeWrapper,
1213
MemoizedChildren,
1314
useEditorRef,
1415
useElement,
15-
usePath,
1616
usePluginOption,
1717
} from 'platejs/react';
1818
import { useSelected } from 'platejs/react';
@@ -72,20 +72,36 @@ export const BlockDraggable: RenderNodeWrapper = (props) => {
7272
function Draggable(props: PlateElementProps) {
7373
const { children, editor, element, path } = props;
7474
const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;
75-
const { isDragging, previewRef, handleRef } = useDraggable({
76-
element,
77-
onDropHandler: (_, { dragItem }) => {
78-
const id = (dragItem as { id: string }).id;
7975

80-
if (blockSelectionApi && id) {
81-
blockSelectionApi.set(id);
82-
}
83-
},
84-
});
76+
const { isDragging, multiplePreviewRef, previewRef, handleRef } =
77+
useDraggable({
78+
element,
79+
onDropHandler: (_, { dragItem }) => {
80+
const id = (dragItem as { id: string[] | string }).id;
81+
82+
if (blockSelectionApi) {
83+
blockSelectionApi.add(id);
84+
}
85+
multiplePreviewRef.current?.replaceChildren();
86+
},
87+
});
8588

8689
const isInColumn = path.length === 3;
8790
const isInTable = path.length === 4;
8891

92+
const [multiplePreviewTop, setMultiplePreviewTop] = React.useState(0);
93+
const [isMultiple, setIsMultiple] = React.useState(false);
94+
95+
// clear up virtual multiple preview when drag end
96+
React.useEffect(() => {
97+
if (!isDragging && isMultiple) {
98+
multiplePreviewRef.current?.replaceChildren();
99+
}
100+
// eslint-disable-next-line react-hooks/exhaustive-deps
101+
}, [isDragging]);
102+
103+
const [dragButtonTop, setDragButtonTop] = React.useState(0);
104+
89105
return (
90106
<div
91107
className={cn(
@@ -95,44 +111,55 @@ function Draggable(props: PlateElementProps) {
95111
? 'group/container'
96112
: 'group'
97113
)}
114+
onMouseEnter={() => {
115+
if (isDragging) return;
116+
setDragButtonTop(calcDragButtonTop(editor, element));
117+
}}
98118
>
99119
{!isInTable && (
100120
<Gutter>
101121
<div
102122
className={cn(
103123
'slate-blockToolbarWrapper',
104124
'flex h-[1.5em]',
105-
isType(editor, element, [
106-
KEYS.h1,
107-
KEYS.h2,
108-
KEYS.h3,
109-
KEYS.h4,
110-
KEYS.h5,
111-
]) && 'h-[1.3em]',
112125
isInColumn && 'h-4'
113126
)}
114127
>
115128
<div
116129
className={cn(
117-
'slate-blockToolbar',
130+
'slate-blockToolbar relative w-4.5',
118131
'pointer-events-auto mr-1 flex items-center',
119132
isInColumn && 'mr-1.5'
120133
)}
121134
>
122135
<Button
123136
ref={handleRef}
124137
variant="ghost"
125-
className="h-6 w-4.5 p-0"
138+
className="absolute -left-0 h-6 w-full p-0"
139+
style={{ top: `${dragButtonTop + 3}px` }}
126140
data-plate-prevent-deselect
127141
>
128-
<DragHandle />
142+
<DragHandle
143+
isDragging={isDragging}
144+
isMultiple={isMultiple}
145+
multiplePreviewRef={multiplePreviewRef}
146+
setIsMultiple={setIsMultiple}
147+
setMultiplePreviewTop={setMultiplePreviewTop}
148+
/>
129149
</Button>
130150
</div>
131151
</div>
132152
</Gutter>
133153
)}
134154

135-
<div ref={previewRef} className="slate-blockWrapper">
155+
<div
156+
ref={multiplePreviewRef}
157+
className={cn('absolute -left-0 hidden w-full')}
158+
style={{ top: `${-multiplePreviewTop}px` }}
159+
contentEditable={false}
160+
/>
161+
162+
<div ref={previewRef} className="slate-blockWrapper flow-root">
136163
<MemoizedChildren>{children}</MemoizedChildren>
137164
<DropLine />
138165
</div>
@@ -147,17 +174,12 @@ function Gutter({
147174
}: React.ComponentProps<'div'>) {
148175
const editor = useEditorRef();
149176
const element = useElement();
150-
const path = usePath();
151177
const isSelectionAreaVisible = usePluginOption(
152178
BlockSelectionPlugin,
153179
'isSelectionAreaVisible'
154180
);
155181
const selected = useSelected();
156182

157-
const isNodeType = (keys: string[] | string) => isType(editor, element, keys);
158-
159-
const isInColumn = path.length === 3;
160-
161183
return (
162184
<div
163185
{...props}
@@ -169,23 +191,6 @@ function Gutter({
169191
: 'group-hover:opacity-100',
170192
isSelectionAreaVisible && 'hidden',
171193
!selected && 'opacity-0',
172-
isNodeType(KEYS.h1) && 'pb-1 text-[1.875em]',
173-
isNodeType(KEYS.h2) && 'pb-1 text-[1.5em]',
174-
isNodeType(KEYS.h3) && 'pt-[2px] pb-1 text-[1.25em]',
175-
isNodeType([KEYS.h4, KEYS.h5]) && 'pt-1 pb-0 text-[1.1em]',
176-
isNodeType(KEYS.h6) && 'pb-0',
177-
isNodeType(KEYS.p) && 'pt-1 pb-0',
178-
isNodeType(KEYS.blockquote) && 'pb-0',
179-
isNodeType(KEYS.codeBlock) && 'pt-6 pb-0',
180-
isNodeType([
181-
KEYS.img,
182-
KEYS.mediaEmbed,
183-
KEYS.excalidraw,
184-
KEYS.toggle,
185-
KEYS.column,
186-
]) && 'py-0',
187-
isNodeType([KEYS.placeholder, KEYS.table]) && 'pt-3 pb-0',
188-
isInColumn && 'mt-2 h-4 pt-0',
189194
className
190195
)}
191196
contentEditable={false}
@@ -195,7 +200,19 @@ function Gutter({
195200
);
196201
}
197202

198-
const DragHandle = React.memo(function DragHandle() {
203+
const DragHandle = React.memo(function DragHandle({
204+
isDragging,
205+
isMultiple,
206+
multiplePreviewRef,
207+
setIsMultiple,
208+
setMultiplePreviewTop,
209+
}: {
210+
isDragging: boolean;
211+
isMultiple: boolean;
212+
multiplePreviewRef: React.RefObject<HTMLDivElement | null>;
213+
setIsMultiple: (isMultiple: boolean) => void;
214+
setMultiplePreviewTop: (top: number) => void;
215+
}) {
199216
const editor = useEditorRef();
200217
const element = useElement();
201218

@@ -209,6 +226,39 @@ const DragHandle = React.memo(function DragHandle() {
209226
.getApi(BlockSelectionPlugin)
210227
.blockSelection.set(element.id as string);
211228
}}
229+
onMouseDown={(e) => {
230+
if (e.button !== 0 || e.shiftKey) return; // Only left mouse button
231+
232+
if (isMultiple) {
233+
const elements = createDragPreviewElements(editor);
234+
multiplePreviewRef.current?.append(...elements);
235+
multiplePreviewRef.current?.classList.remove('hidden');
236+
} else {
237+
editor.setOption(DndPlugin, 'draggingId', null);
238+
return;
239+
}
240+
}}
241+
onMouseEnter={() => {
242+
if (isDragging) return;
243+
244+
const isSelected = editor.getOption(
245+
BlockSelectionPlugin,
246+
'isSelected',
247+
element.id as string
248+
);
249+
250+
if (isSelected) {
251+
const previewTop = calculatePreviewTop(editor, element);
252+
setMultiplePreviewTop(previewTop);
253+
setIsMultiple(true);
254+
} else {
255+
setIsMultiple(false);
256+
}
257+
}}
258+
onMouseUp={() => {
259+
multiplePreviewRef.current?.replaceChildren();
260+
setIsMultiple(false);
261+
}}
212262
role="button"
213263
>
214264
<GripVertical className="text-muted-foreground" />
@@ -241,3 +291,123 @@ const DropLine = React.memo(function DropLine({
241291
/>
242292
);
243293
});
294+
295+
const createDragPreviewElements = (editor: PlateEditor): HTMLElement[] => {
296+
const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;
297+
298+
const sortedNodes = blockSelectionApi.getNodes({
299+
sort: true,
300+
});
301+
302+
const elements: HTMLElement[] = [];
303+
const ids: string[] = [];
304+
305+
/**
306+
* Remove data attributes from the element to avoid recognized as slate
307+
* elements incorrectly.
308+
*/
309+
const removeDataAttributes = (element: HTMLElement) => {
310+
Array.from(element.attributes).forEach((attr) => {
311+
if (
312+
attr.name.startsWith('data-slate') ||
313+
attr.name.startsWith('data-block-id')
314+
) {
315+
element.removeAttribute(attr.name);
316+
}
317+
});
318+
319+
Array.from(element.children).forEach((child) => {
320+
removeDataAttributes(child as HTMLElement);
321+
});
322+
};
323+
324+
const resolveElement = (node: TElement, index: number) => {
325+
const domNode = editor.api.toDOMNode(node)!;
326+
327+
const newDomNode = domNode.cloneNode(true) as HTMLElement;
328+
329+
ids.push(node.id as string);
330+
const wrapper = document.createElement('div');
331+
wrapper.append(newDomNode);
332+
wrapper.style.display = 'flow-root';
333+
334+
const lastDomNode = sortedNodes[index - 1];
335+
336+
if (lastDomNode) {
337+
const lastDomNodeRect = editor.api
338+
.toDOMNode(lastDomNode[0])!
339+
.parentElement!.getBoundingClientRect();
340+
341+
const domNodeRect = domNode.parentElement!.getBoundingClientRect();
342+
343+
const distance = domNodeRect.top - lastDomNodeRect.bottom;
344+
345+
// Check if the two elements are adjacent (touching each other)
346+
if (distance > 15) {
347+
wrapper.style.marginTop = `${distance}px`;
348+
}
349+
}
350+
351+
removeDataAttributes(newDomNode);
352+
elements.push(wrapper);
353+
};
354+
355+
sortedNodes.forEach(([node], index) => resolveElement(node, index));
356+
357+
editor.setOption(DndPlugin, 'draggingId', ids);
358+
359+
return elements;
360+
};
361+
362+
const calculatePreviewTop = (
363+
editor: PlateEditor,
364+
element: TElement
365+
): number => {
366+
const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;
367+
368+
const child = editor.api.toDOMNode(element)!;
369+
const editable = editor.api.toDOMNode(editor)!;
370+
const firstSelectedChild = editor.api.node(blockSelectionApi.first()![0])!;
371+
372+
const firstDomNode = editor.api.toDOMNode(firstSelectedChild[0])!;
373+
// Get editor's top padding
374+
const editorPaddingTop = Number(
375+
window.getComputedStyle(editable).paddingTop.replace('px', '')
376+
);
377+
378+
// Calculate distance from first selected node to editor top
379+
const firstNodeToEditorDistance =
380+
firstDomNode.getBoundingClientRect().top -
381+
editable.getBoundingClientRect().top -
382+
editorPaddingTop;
383+
384+
// Get margin top of first selected node
385+
const firstMarginTopString = window.getComputedStyle(firstDomNode).marginTop;
386+
const marginTop = Number(firstMarginTopString.replace('px', ''));
387+
388+
// Calculate distance from current node to editor top
389+
const currentToEditorDistance =
390+
child.getBoundingClientRect().top -
391+
editable.getBoundingClientRect().top -
392+
editorPaddingTop;
393+
394+
const currentMarginTopString = window.getComputedStyle(child).marginTop;
395+
const currentMarginTop = Number(currentMarginTopString.replace('px', ''));
396+
397+
const previewElementsTopDistance =
398+
currentToEditorDistance -
399+
firstNodeToEditorDistance +
400+
marginTop -
401+
currentMarginTop;
402+
403+
return previewElementsTopDistance;
404+
};
405+
406+
const calcDragButtonTop = (editor: PlateEditor, element: TElement): number => {
407+
const child = editor.api.toDOMNode(element)!;
408+
409+
const currentMarginTopString = window.getComputedStyle(child).marginTop;
410+
const currentMarginTop = Number(currentMarginTopString.replace('px', ''));
411+
412+
return currentMarginTop;
413+
};

0 commit comments

Comments
 (0)