Skip to content

Commit bba5495

Browse files
authored
Merge pull request #4481 from udecode/dnd1
Added support for dragging multiple blocks using editor's native selection
2 parents a555d45 + 53f0247 commit bba5495

File tree

6 files changed

+87
-75
lines changed

6 files changed

+87
-75
lines changed

.changeset/gentle-tigers-listen.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@platejs/dnd': patch
3+
---
4+
5+
Added support for dragging multiple blocks using editor's native selection
6+
7+
- Multiple blocks can now be dragged using the editor's native selection, not just with block-selection
8+
- Simplified `useDndNode` hook implementation by removing complex preview logic

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

Lines changed: 66 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ function Draggable(props: PlateElementProps) {
7373
const { children, editor, element, path } = props;
7474
const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;
7575

76-
const { isDragging, multiplePreviewRef, previewRef, handleRef } =
76+
const { isDragging, nodeRef, previewRef, handleRef } =
7777
useDraggable({
7878
element,
7979
onDropHandler: (_, { dragItem }) => {
@@ -82,20 +82,25 @@ function Draggable(props: PlateElementProps) {
8282
if (blockSelectionApi) {
8383
blockSelectionApi.add(id);
8484
}
85-
multiplePreviewRef.current?.replaceChildren();
85+
resetPreview();
8686
},
8787
});
8888

8989
const isInColumn = path.length === 3;
9090
const isInTable = path.length === 4;
9191

92-
const [multiplePreviewTop, setMultiplePreviewTop] = React.useState(0);
93-
const [isMultiple, setIsMultiple] = React.useState(false);
92+
const [previewTop, setPreviewTop] = React.useState(0);
93+
94+
const resetPreview = () => {
95+
if (previewRef.current) {
96+
previewRef.current.replaceChildren();
97+
}
98+
}
9499

95100
// clear up virtual multiple preview when drag end
96101
React.useEffect(() => {
97-
if (!isDragging && isMultiple) {
98-
multiplePreviewRef.current?.replaceChildren();
102+
if (!isDragging) {
103+
resetPreview();
99104
}
100105
// eslint-disable-next-line react-hooks/exhaustive-deps
101106
}, [isDragging]);
@@ -141,10 +146,8 @@ function Draggable(props: PlateElementProps) {
141146
>
142147
<DragHandle
143148
isDragging={isDragging}
144-
isMultiple={isMultiple}
145-
multiplePreviewRef={multiplePreviewRef}
146-
setIsMultiple={setIsMultiple}
147-
setMultiplePreviewTop={setMultiplePreviewTop}
149+
previewRef={previewRef}
150+
setPreviewTop={setPreviewTop}
148151
/>
149152
</Button>
150153
</div>
@@ -153,13 +156,13 @@ function Draggable(props: PlateElementProps) {
153156
)}
154157

155158
<div
156-
ref={multiplePreviewRef}
159+
ref={previewRef}
157160
className={cn('absolute -left-0 hidden w-full')}
158-
style={{ top: `${-multiplePreviewTop}px` }}
161+
style={{ top: `${-previewTop}px` }}
159162
contentEditable={false}
160163
/>
161164

162-
<div ref={previewRef} className="slate-blockWrapper flow-root">
165+
<div ref={nodeRef} className="slate-blockWrapper flow-root">
163166
<MemoizedChildren>{children}</MemoizedChildren>
164167
<DropLine />
165168
</div>
@@ -202,16 +205,12 @@ function Gutter({
202205

203206
const DragHandle = React.memo(function DragHandle({
204207
isDragging,
205-
isMultiple,
206-
multiplePreviewRef,
207-
setIsMultiple,
208-
setMultiplePreviewTop,
208+
previewRef,
209+
setPreviewTop,
209210
}: {
210211
isDragging: boolean;
211-
isMultiple: boolean;
212-
multiplePreviewRef: React.RefObject<HTMLDivElement | null>;
213-
setIsMultiple: (isMultiple: boolean) => void;
214-
setMultiplePreviewTop: (top: number) => void;
212+
previewRef: React.RefObject<HTMLDivElement | null>;
213+
setPreviewTop: (top: number) => void;
215214
}) {
216215
const editor = useEditorRef();
217216
const element = useElement();
@@ -229,36 +228,37 @@ const DragHandle = React.memo(function DragHandle({
229228
onMouseDown={(e) => {
230229
if (e.button !== 0 || e.shiftKey) return; // Only left mouse button
231230

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-
}
231+
const elements = createDragPreviewElements(editor, { currentBlock: element });
232+
previewRef.current?.append(...elements);
233+
previewRef.current?.classList.remove('hidden');
234+
editor.setOption(DndPlugin, 'multiplePreviewRef', previewRef);
240235
}}
241236
onMouseEnter={() => {
242237
if (isDragging) return;
243238

244-
const isSelected = editor.getOption(
245-
BlockSelectionPlugin,
246-
'isSelected',
247-
element.id as string
248-
);
239+
const blockSelection = editor
240+
.getApi(BlockSelectionPlugin)
241+
.blockSelection.getNodes({ sort: true });
242+
243+
244+
const selectedBlocks =
245+
blockSelection.length > 0
246+
? blockSelection
247+
: editor.api.blocks({ mode: 'highest' });
248+
249+
const ids = selectedBlocks.map((block) => block[0].id as string);
249250

250-
if (isSelected) {
251-
const previewTop = calculatePreviewTop(editor, element);
252-
setMultiplePreviewTop(previewTop);
253-
setIsMultiple(true);
251+
252+
if (ids.length > 1 && ids.includes(element.id as string)) {
253+
const previewTop = calculatePreviewTop(editor, {
254+
blocks: selectedBlocks.map((block) => block[0]),
255+
element,
256+
});
257+
setPreviewTop(previewTop);
254258
} else {
255-
setIsMultiple(false);
259+
setPreviewTop(0);
256260
}
257261
}}
258-
onMouseUp={() => {
259-
multiplePreviewRef.current?.replaceChildren();
260-
setIsMultiple(false);
261-
}}
262262
role="button"
263263
>
264264
<GripVertical className="text-muted-foreground" />
@@ -292,12 +292,19 @@ const DropLine = React.memo(function DropLine({
292292
);
293293
});
294294

295-
const createDragPreviewElements = (editor: PlateEditor): HTMLElement[] => {
296-
const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;
295+
const createDragPreviewElements = (editor: PlateEditor, { currentBlock }: { currentBlock: TElement }): HTMLElement[] => {
296+
const blockSelection = editor.getApi(BlockSelectionPlugin).blockSelection.getNodes({ sort: true });
297+
298+
const selectionNodes = blockSelection.length > 0 ? blockSelection : editor.api.blocks({ mode: 'highest' });
297299

298-
const sortedNodes = blockSelectionApi.getNodes({
299-
sort: true,
300-
});
300+
const includes = selectionNodes.some(([node]) => node.id === currentBlock.id);
301+
302+
const sortedNodes = includes ? selectionNodes.map(([node]) => node) : [currentBlock];
303+
304+
if (blockSelection.length === 0) {
305+
editor.tf.blur();
306+
editor.tf.collapse()
307+
}
301308

302309
const elements: HTMLElement[] = [];
303310
const ids: string[] = [];
@@ -335,7 +342,7 @@ const createDragPreviewElements = (editor: PlateEditor): HTMLElement[] => {
335342

336343
if (lastDomNode) {
337344
const lastDomNodeRect = editor.api
338-
.toDOMNode(lastDomNode[0])!
345+
.toDOMNode(lastDomNode)!
339346
.parentElement!.getBoundingClientRect();
340347

341348
const domNodeRect = domNode.parentElement!.getBoundingClientRect();
@@ -352,7 +359,7 @@ const createDragPreviewElements = (editor: PlateEditor): HTMLElement[] => {
352359
elements.push(wrapper);
353360
};
354361

355-
sortedNodes.forEach(([node], index) => resolveElement(node, index));
362+
sortedNodes.forEach((node, index) => resolveElement(node, index));
356363

357364
editor.setOption(DndPlugin, 'draggingId', ids);
358365

@@ -361,15 +368,20 @@ const createDragPreviewElements = (editor: PlateEditor): HTMLElement[] => {
361368

362369
const calculatePreviewTop = (
363370
editor: PlateEditor,
364-
element: TElement
371+
{
372+
blocks,
373+
element,
374+
}: {
375+
blocks: TElement[];
376+
element: TElement;
377+
}
365378
): number => {
366-
const blockSelectionApi = editor.getApi(BlockSelectionPlugin).blockSelection;
367379

368380
const child = editor.api.toDOMNode(element)!;
369381
const editable = editor.api.toDOMNode(editor)!;
370-
const firstSelectedChild = editor.api.node(blockSelectionApi.first()![0])!;
382+
const firstSelectedChild = blocks[0]
371383

372-
const firstDomNode = editor.api.toDOMNode(firstSelectedChild[0])!;
384+
const firstDomNode = editor.api.toDOMNode(firstSelectedChild)!;
373385
// Get editor's top padding
374386
const editorPaddingTop = Number(
375387
window.getComputedStyle(editable).paddingTop.replace('px', '')

docs/components/changelog.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ Use the [CLI](https://platejs.org/docs/components/cli) to install the latest ver
1010

1111
## July 2025 #24
1212

13+
### July 14 #24.5
14+
- `block-draggable`: Added support for dragging multiple blocks using editor's native selection (previously only block-selection was supported)
15+
1316
### July 3 #24.4
1417
- `slate-to-html`: Added `EditorViewDemo` component for static editor rendering using `createStaticEditor`
1518
### July 4 #24.3

packages/dnd/src/DndPlugin.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export type DndConfig = PluginConfig<
2727
};
2828
enableScroller?: boolean;
2929
isDragging?: boolean;
30+
multiplePreviewRef?: React.RefObject<HTMLDivElement | null> | null;
3031
scrollerProps?: Partial<ScrollerProps>;
3132
onDropFiles?: (props: {
3233
id: string;
@@ -75,13 +76,17 @@ export const DndPlugin = createTPlatePlugin<DndConfig>({
7576
editor.setOption(plugin, 'isDragging', false);
7677
editor.setOption(plugin, 'dropTarget', { id: null, line: '' });
7778
editor.setOption(plugin, '_isOver', false);
79+
editor
80+
.getOption(plugin, 'multiplePreviewRef')
81+
?.current?.replaceChildren();
7882
},
7983
},
8084
options: {
8185
_isOver: false,
8286
draggingId: null,
8387
dropTarget: { id: null, line: '' },
8488
isDragging: false,
89+
multiplePreviewRef: null,
8590
},
8691
}).extend(({ getOptions }) => ({
8792
render: {

packages/dnd/src/components/useDraggable.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { type UseDndNodeOptions, DRAG_ITEM_BLOCK, useDndNode } from '..';
66

77
export type DraggableState = {
88
isDragging: boolean;
9-
/** The ref of the multiple preview element */
10-
multiplePreviewRef: React.RefObject<HTMLDivElement | null>;
119
/** The ref of the draggable element */
10+
nodeRef: React.RefObject<HTMLDivElement | null>;
11+
/** The ref of the multiple preview element */
1212
previewRef: React.RefObject<HTMLDivElement | null>;
1313
/** The ref of the draggable handle */
1414
handleRef: (
@@ -47,8 +47,8 @@ export const useDraggable = (props: UseDndNodeOptions): DraggableState => {
4747

4848
return {
4949
isDragging,
50-
multiplePreviewRef,
51-
previewRef: nodeRef,
50+
nodeRef,
51+
previewRef: multiplePreviewRef,
5252
handleRef: dragRef,
5353
};
5454
};

packages/dnd/src/hooks/useDndNode.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -89,23 +89,7 @@ export const useDndNode = ({
8989
} else if (previewOptions.ref) {
9090
preview(previewOptions.ref);
9191
} else {
92-
const selectedIds = editor.getOption(
93-
{ key: 'blockSelection' },
94-
'selectedIds'
95-
);
96-
97-
const isMultipleSelection =
98-
selectedIds &&
99-
selectedIds.size > 1 &&
100-
selectedIds.has(element.id as string);
101-
102-
if (isMultipleSelection && multiplePreviewRef?.current) {
103-
// Use multiplePreviewRef for preview when dragging multiple blocks
104-
preview(multiplePreviewRef);
105-
} else {
106-
// Use nodeRef for preview when dragging a single block
107-
preview(nodeRef);
108-
}
92+
preview(multiplePreviewRef);
10993
}
11094

11195
return {

0 commit comments

Comments
 (0)