22
33import * as React from 'react' ;
44
5- import { useDraggable , useDropLine } from '@platejs/dnd' ;
5+ import { DndPlugin , useDraggable , useDropLine } from '@platejs/dnd' ;
66import { BlockSelectionPlugin } from '@platejs/selection/react' ;
77import { GripVertical } from 'lucide-react' ;
8- import { getPluginByType , isType , KEYS } from 'platejs' ;
8+ import { type TElement , getPluginByType , isType , KEYS } from 'platejs' ;
99import {
10+ type PlateEditor ,
1011 type PlateElementProps ,
1112 type RenderNodeWrapper ,
1213 MemoizedChildren ,
1314 useEditorRef ,
1415 useElement ,
15- usePath ,
1616 usePluginOption ,
1717} from 'platejs/react' ;
1818import { useSelected } from 'platejs/react' ;
@@ -72,20 +72,36 @@ export const BlockDraggable: RenderNodeWrapper = (props) => {
7272function 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