| 1 | import { |
| 2 | DndPlugin, |
| 3 | type DragItemNode, |
| 4 | type ElementDragItemNode, |
| 5 | } from "@platejs/dnd"; |
| 6 | import { type TElement } from "platejs"; |
| 7 | import { type PlateEditor } from "platejs/react"; |
| 8 | import React from "react"; |
| 9 | import { |
| 10 | useDrag, |
| 11 | type ConnectDragPreview, |
| 12 | type ConnectDragSource, |
| 13 | type DragSourceHookSpec, |
| 14 | } from "react-dnd"; |
| 15 | |
| 16 | import { dropNodeAtFreeformTarget } from "../transforms/onDropNode"; |
| 17 | import { startFreeformDrag, stopFreeformDrag } from "../utils/freeformDrop"; |
| 18 | |
| 19 | export interface UseDragNodeOptions extends DragSourceHookSpec< |
| 20 | DragItemNode, |
| 21 | unknown, |
| 22 | { isDragging: boolean } |
| 23 | > { |
| 24 | disableFreeformDrag?: boolean; |
| 25 | element: TElement; |
| 26 | } |
| 27 | |
| 28 | function isElementDragItem( |
| 29 | dragItem: DragItemNode | undefined, |
| 30 | ): dragItem is ElementDragItemNode { |
| 31 | return Boolean(dragItem && "id" in dragItem && "element" in dragItem); |
| 32 | } |
| 33 | |
| 34 | /** |
| 35 | * `useDrag` hook to drag a node from the editor. `item` with `id` is required. |
| 36 | * |
| 37 | * On drag start: |
| 38 | * - Set `isDragging` to true |
| 39 | * - Add `dragging` class to `body` |
| 40 | * |
| 41 | * On drag end: |
| 42 | * - Set `isDragging` to false |
| 43 | * - Remove `dragging` class from `body` |
| 44 | */ |
| 45 | export const useDragNode = ( |
| 46 | editor: PlateEditor, |
| 47 | { |
| 48 | disableFreeformDrag = false, |
| 49 | element: staleElement, |
| 50 | item, |
| 51 | ...options |
| 52 | }: UseDragNodeOptions, |
| 53 | ): [ |
| 54 | { isAboutToDrag: boolean; isDragging: boolean }, |
| 55 | ConnectDragSource, |
| 56 | ConnectDragPreview, |
| 57 | ] => { |
| 58 | const elementId = staleElement.id as string; |
| 59 | const [isAboutToDrag, setIsAboutToDrag] = React.useState(false); |
| 60 | |
| 61 | const [collected, dragRef, preview] = useDrag< |
| 62 | DragItemNode, |
| 63 | unknown, |
| 64 | { isDragging: boolean } |
| 65 | >( |
| 66 | () => ({ |
| 67 | canDrag: () => { |
| 68 | setIsAboutToDrag(true); |
| 69 | return true; |
| 70 | }, |
| 71 | collect: (monitor) => ({ |
| 72 | isDragging: monitor.isDragging(), |
| 73 | }), |
| 74 | end: (dragItem, monitor) => { |
| 75 | if ( |
| 76 | !disableFreeformDrag && |
| 77 | !monitor.didDrop() && |
| 78 | isElementDragItem(dragItem) |
| 79 | ) { |
| 80 | dropNodeAtFreeformTarget(editor, dragItem); |
| 81 | } |
| 82 | |
| 83 | stopFreeformDrag(editor); |
| 84 | editor.setOption(DndPlugin, "isDragging", false); |
| 85 | document.body.classList.remove("dragging"); |
| 86 | setIsAboutToDrag(false); |
| 87 | }, |
| 88 | item(monitor) { |
| 89 | editor.setOption(DndPlugin, "isDragging", true); |
| 90 | editor.setOption(DndPlugin, "_isOver", true); |
| 91 | document.body.classList.add("dragging"); |
| 92 | |
| 93 | // Check if multiple nodes are selected |
| 94 | const currentDraggingId = editor.getOption(DndPlugin, "draggingId"); |
| 95 | let id: string[] | string; |
| 96 | |
| 97 | if ( |
| 98 | Array.isArray(currentDraggingId) && |
| 99 | currentDraggingId.length > 1 && |
| 100 | currentDraggingId.includes(elementId) |
| 101 | ) { |
| 102 | // Multiple selection including current element |
| 103 | id = Array.from(currentDraggingId); |
| 104 | } else { |
| 105 | // Single element drag |
| 106 | id = elementId; |
| 107 | editor.setOption(DndPlugin, "draggingId", elementId); |
| 108 | } |
| 109 | |
| 110 | // Get the fresh element from the editor, or fall back to the stale element |
| 111 | // This handles external/synthetic elements (like RootImage) that don't exist in the editor |
| 112 | const nodeEntry = editor.api.node({ id: elementId, at: [] }); |
| 113 | const element = |
| 114 | (nodeEntry?.[0] as TElement | undefined) ?? staleElement; |
| 115 | |
| 116 | const optionItem = typeof item === "function" ? item(monitor) : item; |
| 117 | const extraItem = |
| 118 | optionItem && typeof optionItem === "object" ? optionItem : {}; |
| 119 | const dragNodeItem: ElementDragItemNode = { |
| 120 | ...extraItem, |
| 121 | id, |
| 122 | editorId: editor.id, |
| 123 | element, |
| 124 | }; |
| 125 | |
| 126 | if (!disableFreeformDrag) { |
| 127 | startFreeformDrag(editor, dragNodeItem); |
| 128 | } |
| 129 | |
| 130 | return dragNodeItem; |
| 131 | }, |
| 132 | ...options, |
| 133 | }), |
| 134 | [disableFreeformDrag, editor, elementId], |
| 135 | ); |
| 136 | |
| 137 | // Reset isAboutToDrag when drag is cancelled (e.g., ESC key) |
| 138 | React.useEffect(() => { |
| 139 | if (!collected.isDragging && isAboutToDrag) { |
| 140 | setIsAboutToDrag(false); |
| 141 | } |
| 142 | }, [collected.isDragging, isAboutToDrag]); |
| 143 | |
| 144 | return [{ ...collected, isAboutToDrag }, dragRef, preview]; |
| 145 | }; |
| 146 |