| 1 | import { |
| 2 | DndPlugin, |
| 3 | DRAG_ITEM_BLOCK, |
| 4 | type DragItemNode, |
| 5 | type DropLineDirection, |
| 6 | type ElementDragItemNode, |
| 7 | } from "@platejs/dnd"; |
| 8 | import { PathApi, type Path, type TElement } from "platejs"; |
| 9 | import { type PlateEditor } from "platejs/react"; |
| 10 | import React from "react"; |
| 11 | import { useDrop, type DropTargetMonitor } from "react-dnd"; |
| 12 | |
| 13 | import { applyDropPathResult } from "../transforms/onDropNode"; |
| 14 | import { canDropAtPath, type DropPathResult } from "../utils/getDropPath"; |
| 15 | |
| 16 | type EditorSurfacePlacement = "top" | "bottom"; |
| 17 | |
| 18 | type EditorElementDragItem = DragItemNode & { |
| 19 | element: TElement; |
| 20 | id: string | string[]; |
| 21 | }; |
| 22 | |
| 23 | type EditorSurfaceDropResult = { |
| 24 | droppedOnEditorSurface: true; |
| 25 | }; |
| 26 | |
| 27 | type SurfaceDropTarget = { |
| 28 | id: string; |
| 29 | line: DropLineDirection; |
| 30 | }; |
| 31 | |
| 32 | function isEditorElementDragItem( |
| 33 | dragItem: DragItemNode | undefined, |
| 34 | ): dragItem is EditorElementDragItem { |
| 35 | return Boolean( |
| 36 | dragItem && |
| 37 | "id" in dragItem && |
| 38 | "element" in dragItem && |
| 39 | typeof dragItem.element === "object" && |
| 40 | dragItem.element !== null, |
| 41 | ); |
| 42 | } |
| 43 | |
| 44 | function getDragIds(dragItem: EditorElementDragItem): string[] { |
| 45 | return Array.isArray(dragItem.id) ? dragItem.id : [dragItem.id]; |
| 46 | } |
| 47 | |
| 48 | function getPrimaryDragPath( |
| 49 | editor: PlateEditor, |
| 50 | dragItem: EditorElementDragItem, |
| 51 | ): Path | undefined { |
| 52 | const primaryDragId = getDragIds(dragItem).find( |
| 53 | (id) => typeof id === "string", |
| 54 | ); |
| 55 | |
| 56 | if (!primaryDragId) return undefined; |
| 57 | |
| 58 | const entry = editor.api.node({ id: primaryDragId, at: [] }); |
| 59 | |
| 60 | return entry?.[1]; |
| 61 | } |
| 62 | |
| 63 | function toElementDragItemNode( |
| 64 | editor: PlateEditor, |
| 65 | dragItem: EditorElementDragItem, |
| 66 | ): ElementDragItemNode { |
| 67 | return "editorId" in dragItem |
| 68 | ? dragItem |
| 69 | : { |
| 70 | ...dragItem, |
| 71 | editorId: editor.id, |
| 72 | }; |
| 73 | } |
| 74 | |
| 75 | function getEditorSurfacePlacement( |
| 76 | root: HTMLElement | null, |
| 77 | monitor: DropTargetMonitor<DragItemNode, unknown>, |
| 78 | ): EditorSurfacePlacement | undefined { |
| 79 | const clientOffset = monitor.getClientOffset(); |
| 80 | |
| 81 | if (!root || !clientOffset) return undefined; |
| 82 | |
| 83 | const rect = root.getBoundingClientRect(); |
| 84 | |
| 85 | return clientOffset.y < rect.top + rect.height / 2 ? "top" : "bottom"; |
| 86 | } |
| 87 | |
| 88 | function getEditorEdgeDropPathResult( |
| 89 | editor: PlateEditor, |
| 90 | dragItem: EditorElementDragItem, |
| 91 | placement: EditorSurfacePlacement, |
| 92 | ): DropPathResult | undefined { |
| 93 | const childCount = editor.children.length; |
| 94 | const dragPath = getPrimaryDragPath(editor, dragItem); |
| 95 | |
| 96 | if (childCount === 0) { |
| 97 | return { |
| 98 | createColumns: false, |
| 99 | direction: placement, |
| 100 | dragPath, |
| 101 | hoveredPath: [0], |
| 102 | isExternalNode: !dragPath, |
| 103 | to: [0], |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | if (placement === "top") { |
| 108 | const firstPath: Path = [0]; |
| 109 | |
| 110 | if (dragPath && PathApi.equals(dragPath, firstPath)) return undefined; |
| 111 | |
| 112 | return { |
| 113 | createColumns: false, |
| 114 | direction: "top", |
| 115 | dragPath, |
| 116 | hoveredPath: firstPath, |
| 117 | isExternalNode: !dragPath, |
| 118 | to: firstPath, |
| 119 | }; |
| 120 | } |
| 121 | |
| 122 | const lastPath: Path = [childCount - 1]; |
| 123 | |
| 124 | if (dragPath && PathApi.equals(dragPath, lastPath)) return undefined; |
| 125 | |
| 126 | const to = |
| 127 | dragPath && |
| 128 | PathApi.isBefore(dragPath, lastPath) && |
| 129 | PathApi.isSibling(dragPath, lastPath) |
| 130 | ? lastPath |
| 131 | : PathApi.next(lastPath); |
| 132 | |
| 133 | return { |
| 134 | createColumns: false, |
| 135 | direction: "bottom", |
| 136 | dragPath, |
| 137 | hoveredPath: lastPath, |
| 138 | isExternalNode: !dragPath, |
| 139 | to, |
| 140 | }; |
| 141 | } |
| 142 | |
| 143 | function getSurfaceDropTarget( |
| 144 | editor: PlateEditor, |
| 145 | placement: EditorSurfacePlacement, |
| 146 | ): SurfaceDropTarget | null { |
| 147 | const childCount = editor.children.length; |
| 148 | |
| 149 | if (childCount === 0) return null; |
| 150 | |
| 151 | const targetPath: Path = placement === "top" ? [0] : [childCount - 1]; |
| 152 | const target = editor.api.node({ at: targetPath })?.[0]; |
| 153 | const targetId = |
| 154 | typeof target === "object" && |
| 155 | target !== null && |
| 156 | "id" in target && |
| 157 | typeof target.id === "string" |
| 158 | ? target.id |
| 159 | : undefined; |
| 160 | |
| 161 | if (!targetId) return null; |
| 162 | |
| 163 | return { |
| 164 | id: targetId, |
| 165 | line: placement, |
| 166 | }; |
| 167 | } |
| 168 | |
| 169 | function useSurfaceDropTargetState(editor: PlateEditor) { |
| 170 | const activeSurfaceTargetRef = React.useRef<SurfaceDropTarget | null>(null); |
| 171 | |
| 172 | const clearSurfaceDropTarget = React.useCallback(() => { |
| 173 | const activeTarget = activeSurfaceTargetRef.current; |
| 174 | |
| 175 | if (!activeTarget) return; |
| 176 | |
| 177 | const current = editor.getOptions(DndPlugin).dropTarget; |
| 178 | |
| 179 | if ( |
| 180 | current?.id === activeTarget.id && |
| 181 | current?.line === activeTarget.line |
| 182 | ) { |
| 183 | editor.setOption(DndPlugin, "dropTarget", { id: null, line: "" }); |
| 184 | } |
| 185 | |
| 186 | activeSurfaceTargetRef.current = null; |
| 187 | }, [editor]); |
| 188 | |
| 189 | const setSurfaceDropTarget = React.useCallback( |
| 190 | (target: SurfaceDropTarget | null) => { |
| 191 | if (!target) { |
| 192 | clearSurfaceDropTarget(); |
| 193 | return; |
| 194 | } |
| 195 | |
| 196 | const current = editor.getOptions(DndPlugin).dropTarget; |
| 197 | |
| 198 | activeSurfaceTargetRef.current = target; |
| 199 | |
| 200 | if (current?.id === target.id && current?.line === target.line) { |
| 201 | return; |
| 202 | } |
| 203 | |
| 204 | editor.setOption(DndPlugin, "dropTarget", target); |
| 205 | }, |
| 206 | [clearSurfaceDropTarget, editor], |
| 207 | ); |
| 208 | |
| 209 | return { |
| 210 | clearSurfaceDropTarget, |
| 211 | setSurfaceDropTarget, |
| 212 | }; |
| 213 | } |
| 214 | |
| 215 | export function useEditorSurfaceDrop({ |
| 216 | disabled, |
| 217 | editor, |
| 218 | }: { |
| 219 | disabled: boolean; |
| 220 | editor: PlateEditor; |
| 221 | }): { |
| 222 | isEditorSurfaceDropActive: boolean; |
| 223 | setEditorSurfaceDropRef: React.RefCallback<HTMLDivElement>; |
| 224 | } { |
| 225 | const rootRef = React.useRef<HTMLDivElement | null>(null); |
| 226 | const { clearSurfaceDropTarget, setSurfaceDropTarget } = |
| 227 | useSurfaceDropTargetState(editor); |
| 228 | |
| 229 | const [{ canDrop, isOver }, drop] = useDrop< |
| 230 | DragItemNode, |
| 231 | EditorSurfaceDropResult | undefined, |
| 232 | { canDrop: boolean; isOver: boolean } |
| 233 | >( |
| 234 | () => ({ |
| 235 | accept: [DRAG_ITEM_BLOCK], |
| 236 | canDrop: (dragItem) => !disabled && isEditorElementDragItem(dragItem), |
| 237 | collect: (monitor) => ({ |
| 238 | canDrop: monitor.canDrop(), |
| 239 | isOver: monitor.isOver({ shallow: true }), |
| 240 | }), |
| 241 | drop: (dragItem, monitor) => { |
| 242 | if ( |
| 243 | monitor.didDrop() || |
| 244 | disabled || |
| 245 | !isEditorElementDragItem(dragItem) |
| 246 | ) { |
| 247 | return undefined; |
| 248 | } |
| 249 | |
| 250 | const placement = getEditorSurfacePlacement(rootRef.current, monitor); |
| 251 | |
| 252 | if (!placement) return undefined; |
| 253 | |
| 254 | const result = getEditorEdgeDropPathResult(editor, dragItem, placement); |
| 255 | |
| 256 | if (!result || !canDropAtPath(editor, dragItem, result)) { |
| 257 | return undefined; |
| 258 | } |
| 259 | |
| 260 | applyDropPathResult(editor, { |
| 261 | dragItem: toElementDragItemNode(editor, dragItem), |
| 262 | result, |
| 263 | }); |
| 264 | clearSurfaceDropTarget(); |
| 265 | |
| 266 | return { droppedOnEditorSurface: true }; |
| 267 | }, |
| 268 | hover: (dragItem, monitor) => { |
| 269 | if ( |
| 270 | disabled || |
| 271 | !monitor.isOver({ shallow: true }) || |
| 272 | !isEditorElementDragItem(dragItem) |
| 273 | ) { |
| 274 | return; |
| 275 | } |
| 276 | |
| 277 | const placement = getEditorSurfacePlacement(rootRef.current, monitor); |
| 278 | |
| 279 | if (!placement) { |
| 280 | clearSurfaceDropTarget(); |
| 281 | return; |
| 282 | } |
| 283 | |
| 284 | setSurfaceDropTarget(getSurfaceDropTarget(editor, placement)); |
| 285 | }, |
| 286 | }), |
| 287 | [clearSurfaceDropTarget, disabled, editor, setSurfaceDropTarget], |
| 288 | ); |
| 289 | |
| 290 | React.useEffect(() => { |
| 291 | if (!isOver || !canDrop) { |
| 292 | clearSurfaceDropTarget(); |
| 293 | } |
| 294 | }, [canDrop, clearSurfaceDropTarget, isOver]); |
| 295 | |
| 296 | React.useEffect(() => clearSurfaceDropTarget, [clearSurfaceDropTarget]); |
| 297 | |
| 298 | const setEditorSurfaceDropRef = React.useCallback( |
| 299 | (node: HTMLDivElement | null) => { |
| 300 | rootRef.current = node; |
| 301 | drop(node); |
| 302 | }, |
| 303 | [drop], |
| 304 | ); |
| 305 | |
| 306 | return { |
| 307 | isEditorSurfaceDropActive: isOver && canDrop, |
| 308 | setEditorSurfaceDropRef, |
| 309 | }; |
| 310 | } |
| 311 |