| 1 | "use client"; |
| 2 | |
| 3 | import { DndPlugin } from "@platejs/dnd"; |
| 4 | import { expandListItemsWithChildren } from "@platejs/list"; |
| 5 | import { BlockSelectionPlugin } from "@platejs/selection/react"; |
| 6 | import { GripHorizontal, GripVertical } from "lucide-react"; |
| 7 | import { motion } from "motion/react"; |
| 8 | import { |
| 9 | getContainerTypes, |
| 10 | isType, |
| 11 | KEYS, |
| 12 | nanoid, |
| 13 | PathApi, |
| 14 | type TElement, |
| 15 | } from "platejs"; |
| 16 | import { |
| 17 | MemoizedChildren, |
| 18 | useEditorRef, |
| 19 | useEditorSelector, |
| 20 | useElement, |
| 21 | useFocused, |
| 22 | usePath, |
| 23 | usePluginOption, |
| 24 | useSelected, |
| 25 | type PlateEditor, |
| 26 | type PlateElementProps, |
| 27 | type RenderNodeWrapper, |
| 28 | } from "platejs/react"; |
| 29 | import * as React from "react"; |
| 30 | |
| 31 | import { useDraggable } from "@/components/notebook/presentation/editor/dnd/hooks/useDraggable"; |
| 32 | import { useDropLine } from "@/components/notebook/presentation/editor/dnd/hooks/useDropLine"; |
| 33 | import { type CanDropCallback } from "@/components/notebook/presentation/editor/dnd/hooks/useDropNode"; |
| 34 | import { |
| 35 | BLOCKS, |
| 36 | BUTTON_ELEMENT, |
| 37 | COLUMN_GROUP, |
| 38 | getGridClassForElement, |
| 39 | getGridStyleForElement, |
| 40 | isLayoutChildType, |
| 41 | LABEL_ELEMENT, |
| 42 | QUOTE_ELEMENT, |
| 43 | } from "@/components/notebook/presentation/editor/lib"; |
| 44 | import { useIsTouchDevice } from "@/components/plate/hooks/use-is-touch-device"; |
| 45 | import { Button } from "@/components/plate/ui/button"; |
| 46 | import { |
| 47 | Tooltip, |
| 48 | TooltipContent, |
| 49 | TooltipTrigger, |
| 50 | } from "@/components/plate/ui/tooltip"; |
| 51 | import { cn } from "@/lib/utils"; |
| 52 | |
| 53 | // Configuration constants |
| 54 | const UNDRAGGABLE_KEYS = [KEYS.tr, KEYS.td, KEYS.codeLine]; |
| 55 | |
| 56 | const NON_DRAGGABLE_DESCENDANT_CONTAINER_TYPES: readonly string[] = [ |
| 57 | KEYS.callout, |
| 58 | KEYS.codeBlock, |
| 59 | KEYS.blockquote, |
| 60 | QUOTE_ELEMENT, |
| 61 | BUTTON_ELEMENT, |
| 62 | LABEL_ELEMENT, |
| 63 | ]; |
| 64 | |
| 65 | // Elements that should have horizontal orientation |
| 66 | |
| 67 | // Elements that can only drop within same parent (sibling-only drops) |
| 68 | const SIBLING_ONLY_DROP_ELEMENTS = ["column", "table-row", "list-item"]; |
| 69 | const GUTTER_HIDE_DELAY_MS = 300; |
| 70 | const VERTICAL_GUTTER_SAFE_ZONE_PX = 40; |
| 71 | const HORIZONTAL_GUTTER_SAFE_ZONE_PX = 28; |
| 72 | |
| 73 | type PointerCoordinates = { |
| 74 | clientX: number; |
| 75 | clientY: number; |
| 76 | }; |
| 77 | |
| 78 | type ElementWithRenderToken = TElement & { |
| 79 | lastUpdate?: number | string; |
| 80 | }; |
| 81 | |
| 82 | // Helper function to determine element orientation |
| 83 | |
| 84 | // Helper function to check if element requires sibling-only drops |
| 85 | const requiresSiblingOnlyDrop = (elementType: string): boolean => { |
| 86 | return SIBLING_ONLY_DROP_ELEMENTS.includes(elementType); |
| 87 | }; |
| 88 | |
| 89 | function isElementWithStringType( |
| 90 | node: unknown, |
| 91 | ): node is TElement & { type: string } { |
| 92 | return ( |
| 93 | typeof node === "object" && |
| 94 | node !== null && |
| 95 | "type" in node && |
| 96 | typeof node.type === "string" |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | function hasNonDraggableContainerAncestor( |
| 101 | editor: PlateEditor, |
| 102 | path: number[], |
| 103 | ): boolean { |
| 104 | let ancestorPath = PathApi.parent(path); |
| 105 | |
| 106 | while (ancestorPath.length > 0) { |
| 107 | const ancestorEntry = editor.api.node({ at: ancestorPath }); |
| 108 | const ancestorNode = ancestorEntry?.[0]; |
| 109 | |
| 110 | if ( |
| 111 | isElementWithStringType(ancestorNode) && |
| 112 | NON_DRAGGABLE_DESCENDANT_CONTAINER_TYPES.includes(ancestorNode.type) |
| 113 | ) { |
| 114 | return true; |
| 115 | } |
| 116 | |
| 117 | ancestorPath = PathApi.parent(ancestorPath); |
| 118 | } |
| 119 | |
| 120 | return false; |
| 121 | } |
| 122 | |
| 123 | export const BlockDraggable: RenderNodeWrapper = (props) => { |
| 124 | const { editor, element, path } = props; |
| 125 | const enabled = React.useMemo(() => { |
| 126 | if (!path) return false; |
| 127 | |
| 128 | if ( |
| 129 | !editor.api.isBlock(element) && |
| 130 | element.type !== BUTTON_ELEMENT && |
| 131 | element.type !== LABEL_ELEMENT |
| 132 | ) { |
| 133 | return false; |
| 134 | } |
| 135 | |
| 136 | // Inline elements like links should never receive block drag wrappers. |
| 137 | if (editor.api.isInline(element)) return false; |
| 138 | |
| 139 | // Check if element is undraggable |
| 140 | if (isType(editor, element, UNDRAGGABLE_KEYS)) return false; |
| 141 | |
| 142 | if (hasNonDraggableContainerAncestor(editor, path)) return false; |
| 143 | |
| 144 | // Enable dragging for top-level blocks and explicit layout children. |
| 145 | if (path.length === 1) return true; |
| 146 | if (path.length === 2) return isLayoutChildType(element.type); |
| 147 | |
| 148 | if (path.length === 3) { |
| 149 | const isInColumn = editor.api.some({ |
| 150 | at: path, |
| 151 | match: { type: editor.getType(KEYS.column) }, |
| 152 | }); |
| 153 | return isInColumn; |
| 154 | } |
| 155 | |
| 156 | if (path.length === 4) { |
| 157 | const isInTable = editor.api.some({ |
| 158 | at: path, |
| 159 | match: { type: editor.getType(KEYS.table) }, |
| 160 | }); |
| 161 | return isInTable; |
| 162 | } |
| 163 | |
| 164 | return false; |
| 165 | }, [editor, element, path]); |
| 166 | |
| 167 | return enabled |
| 168 | ? (draggableProps) => <Draggable {...draggableProps} /> |
| 169 | : ({ children }) => <>{children}</>; |
| 170 | }; |
| 171 | |
| 172 | function Draggable(props: PlateElementProps) { |
| 173 | const { children, editor, element, path } = props; |
| 174 | const pathKey = path.join("."); |
| 175 | |
| 176 | React.useEffect(() => { |
| 177 | if (typeof element.id === "string" && element.id) return; |
| 178 | |
| 179 | editor.tf.setNodes({ id: nanoid() }, { at: path }); |
| 180 | }, [editor, element.id, path, pathKey]); |
| 181 | |
| 182 | // Determine if this element can create columns when dropped on sides |
| 183 | // Root level elements (path.length === 1) can create columns |
| 184 | const canCreateColumns = path.length === 1; |
| 185 | const isChildOfColumnItem = React.useMemo(() => { |
| 186 | if (!path || path.length === 0) return false; |
| 187 | try { |
| 188 | const parentNode = editor.api.node({ at: PathApi.parent(path) }); |
| 189 | return parentNode?.[0]?.type === "column"; |
| 190 | } catch { |
| 191 | return false; |
| 192 | } |
| 193 | }, [editor, path]); |
| 194 | |
| 195 | // Orientation is for UI styling and freeform drop-axis detection. |
| 196 | // path.length === 2 means elements inside a layout block like bullet/cycle - show horizontal grip |
| 197 | const orientation: "horizontal" | "vertical" = |
| 198 | path.length === 2 && !isChildOfColumnItem ? "horizontal" : "vertical"; |
| 199 | const canDropNode = React.useCallback<CanDropCallback>( |
| 200 | ({ dragEntry, dropEntry }) => { |
| 201 | const dragElementType = dragEntry[0].type; |
| 202 | |
| 203 | // Check if this element requires sibling-only drops |
| 204 | if (requiresSiblingOnlyDrop(dragElementType)) { |
| 205 | const dragParentPath = PathApi.parent(dragEntry[1]); |
| 206 | const dropParentPath = PathApi.parent(dropEntry[1]); |
| 207 | |
| 208 | // First check: Direct siblings (same parent) |
| 209 | if (PathApi.equals(dragParentPath, dropParentPath)) { |
| 210 | return true; |
| 211 | } |
| 212 | |
| 213 | // Second check: Check if drop target is a child of a valid sibling |
| 214 | // We need to traverse up the drop entry's ancestors to see if any of them |
| 215 | // are siblings of the drag entry |
| 216 | let currentDropPath = dropEntry[1]; |
| 217 | |
| 218 | while (currentDropPath.length > 0) { |
| 219 | const currentParentPath = PathApi.parent(currentDropPath); |
| 220 | |
| 221 | // If we found a path where the parent matches our drag element's parent, |
| 222 | // then the drop target is within a valid sibling |
| 223 | if (PathApi.equals(dragParentPath, currentParentPath)) { |
| 224 | // Additional check: make sure the sibling element is the same type as drag element |
| 225 | // This ensures we're dropping within a column if we're dragging a column, etc. |
| 226 | const siblingPath = currentDropPath; |
| 227 | const siblingEntry = editor.api.node({ at: siblingPath }); |
| 228 | |
| 229 | if (siblingEntry && siblingEntry[0].type === dragElementType) { |
| 230 | return true; |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | // Move up one level |
| 235 | currentDropPath = PathApi.parent(currentDropPath); |
| 236 | } |
| 237 | |
| 238 | // If no valid sibling relationship found, disallow the drop |
| 239 | return false; |
| 240 | } |
| 241 | |
| 242 | // Default behavior: allow drops anywhere |
| 243 | return true; |
| 244 | }, |
| 245 | [editor], |
| 246 | ); |
| 247 | const { isAboutToDrag, isDragging, nodeRef, previewRef, handleRef } = |
| 248 | useDraggable({ |
| 249 | element, |
| 250 | canCreateColumns, |
| 251 | orientation, |
| 252 | onDropHandler: () => { |
| 253 | resetPreview(); |
| 254 | return undefined; |
| 255 | }, |
| 256 | canDropNode, |
| 257 | }); |
| 258 | |
| 259 | const isInColumn = path.length === 3 || isChildOfColumnItem; |
| 260 | const isInTable = path.length === 4; |
| 261 | const isContentHeightElement = |
| 262 | element.type === QUOTE_ELEMENT || element.type === COLUMN_GROUP; |
| 263 | const elementRenderToken = (element as ElementWithRenderToken).lastUpdate; |
| 264 | const childrenRenderKey = `${String(element.id ?? element.type)}:${path.join( |
| 265 | ".", |
| 266 | )}:${String(elementRenderToken ?? "")}`; |
| 267 | const showHoverBorder = React.useMemo( |
| 268 | () => BLOCKS.some((block) => block.type === element.type), |
| 269 | [element.type], |
| 270 | ); |
| 271 | |
| 272 | const [previewTop, setPreviewTop] = React.useState(0); |
| 273 | const [isGutterActive, setIsGutterActive] = React.useState(false); |
| 274 | const gutterHideTimeoutRef = React.useRef<number | null>(null); |
| 275 | const pointerTrackerCleanupRef = React.useRef<(() => void) | null>(null); |
| 276 | const pointerTrackerStateRef = React.useRef<{ |
| 277 | isAboutToDrag: boolean; |
| 278 | isDragging: boolean; |
| 279 | isPointerInGutterSafeZone: (coordinates: PointerCoordinates) => boolean; |
| 280 | keepGutterOpen: () => void; |
| 281 | scheduleHideGutter: () => void; |
| 282 | } | null>(null); |
| 283 | |
| 284 | const clearGutterHideTimeout = React.useCallback(() => { |
| 285 | if (gutterHideTimeoutRef.current !== null) { |
| 286 | window.clearTimeout(gutterHideTimeoutRef.current); |
| 287 | gutterHideTimeoutRef.current = null; |
| 288 | } |
| 289 | }, []); |
| 290 | |
| 291 | const stopPointerTracking = React.useCallback(() => { |
| 292 | pointerTrackerCleanupRef.current?.(); |
| 293 | pointerTrackerCleanupRef.current = null; |
| 294 | }, []); |
| 295 | |
| 296 | const startPointerTracking = React.useCallback(() => { |
| 297 | if (pointerTrackerCleanupRef.current !== null) { |
| 298 | return; |
| 299 | } |
| 300 | |
| 301 | const handlePointerMove = (event: PointerEvent) => { |
| 302 | const trackerState = pointerTrackerStateRef.current; |
| 303 | |
| 304 | if (!trackerState) return; |
| 305 | |
| 306 | if ( |
| 307 | trackerState.isAboutToDrag || |
| 308 | trackerState.isDragging || |
| 309 | trackerState.isPointerInGutterSafeZone(event) |
| 310 | ) { |
| 311 | trackerState.keepGutterOpen(); |
| 312 | return; |
| 313 | } |
| 314 | |
| 315 | trackerState.scheduleHideGutter(); |
| 316 | }; |
| 317 | |
| 318 | window.addEventListener("pointermove", handlePointerMove); |
| 319 | window.addEventListener("pointerdown", handlePointerMove); |
| 320 | pointerTrackerCleanupRef.current = () => { |
| 321 | window.removeEventListener("pointermove", handlePointerMove); |
| 322 | window.removeEventListener("pointerdown", handlePointerMove); |
| 323 | }; |
| 324 | }, []); |
| 325 | |
| 326 | const keepGutterOpen = React.useCallback(() => { |
| 327 | clearGutterHideTimeout(); |
| 328 | setIsGutterActive(true); |
| 329 | }, [clearGutterHideTimeout]); |
| 330 | |
| 331 | const showGutter = React.useCallback(() => { |
| 332 | keepGutterOpen(); |
| 333 | startPointerTracking(); |
| 334 | }, [keepGutterOpen, startPointerTracking]); |
| 335 | |
| 336 | const isPointerInGutterSafeZone = React.useCallback( |
| 337 | ({ clientX, clientY }: PointerCoordinates) => { |
| 338 | const wrapper = nodeRef.current; |
| 339 | |
| 340 | if (!wrapper) return false; |
| 341 | |
| 342 | const rect = wrapper.getBoundingClientRect(); |
| 343 | |
| 344 | if (orientation === "horizontal") { |
| 345 | return ( |
| 346 | clientX >= rect.left && |
| 347 | clientX <= rect.right && |
| 348 | clientY >= rect.top - HORIZONTAL_GUTTER_SAFE_ZONE_PX && |
| 349 | clientY <= rect.bottom |
| 350 | ); |
| 351 | } |
| 352 | |
| 353 | return ( |
| 354 | clientX >= rect.left - VERTICAL_GUTTER_SAFE_ZONE_PX && |
| 355 | clientX <= rect.right && |
| 356 | clientY >= rect.top && |
| 357 | clientY <= rect.bottom |
| 358 | ); |
| 359 | }, |
| 360 | [nodeRef, orientation], |
| 361 | ); |
| 362 | |
| 363 | const scheduleHideGutter = React.useCallback(() => { |
| 364 | clearGutterHideTimeout(); |
| 365 | gutterHideTimeoutRef.current = window.setTimeout(() => { |
| 366 | setIsGutterActive(false); |
| 367 | gutterHideTimeoutRef.current = null; |
| 368 | stopPointerTracking(); |
| 369 | }, GUTTER_HIDE_DELAY_MS); |
| 370 | }, [clearGutterHideTimeout, stopPointerTracking]); |
| 371 | |
| 372 | const hideGutter = React.useCallback( |
| 373 | (event: React.PointerEvent<HTMLDivElement>) => { |
| 374 | if (isPointerInGutterSafeZone(event.nativeEvent)) { |
| 375 | return; |
| 376 | } |
| 377 | |
| 378 | scheduleHideGutter(); |
| 379 | }, |
| 380 | [isPointerInGutterSafeZone, scheduleHideGutter], |
| 381 | ); |
| 382 | |
| 383 | React.useEffect(() => { |
| 384 | pointerTrackerStateRef.current = { |
| 385 | isAboutToDrag, |
| 386 | isDragging, |
| 387 | isPointerInGutterSafeZone, |
| 388 | keepGutterOpen, |
| 389 | scheduleHideGutter, |
| 390 | }; |
| 391 | }, [ |
| 392 | isAboutToDrag, |
| 393 | isDragging, |
| 394 | isPointerInGutterSafeZone, |
| 395 | keepGutterOpen, |
| 396 | scheduleHideGutter, |
| 397 | ]); |
| 398 | |
| 399 | const resetPreview = () => { |
| 400 | if (previewRef.current) { |
| 401 | previewRef.current.replaceChildren(); |
| 402 | previewRef.current?.classList.add("hidden"); |
| 403 | } |
| 404 | }; |
| 405 | |
| 406 | // Clear up virtual multiple preview when drag ends |
| 407 | React.useEffect(() => { |
| 408 | if (!isDragging) { |
| 409 | resetPreview(); |
| 410 | } |
| 411 | }, [isDragging, previewRef]); |
| 412 | |
| 413 | React.useEffect(() => { |
| 414 | if (isAboutToDrag) { |
| 415 | previewRef.current?.classList.remove("opacity-0"); |
| 416 | } |
| 417 | }, [isAboutToDrag, previewRef]); |
| 418 | |
| 419 | React.useEffect(() => { |
| 420 | return () => { |
| 421 | clearGutterHideTimeout(); |
| 422 | stopPointerTracking(); |
| 423 | }; |
| 424 | }, [clearGutterHideTimeout, stopPointerTracking]); |
| 425 | |
| 426 | return ( |
| 427 | <div |
| 428 | data-dnd-wrapper="true" |
| 429 | className={cn( |
| 430 | path?.length === 1 && "px-4 md:px-8", |
| 431 | // path?.length === 2 && "pl-8", |
| 432 | getGridClassForElement( |
| 433 | editor as unknown as PlateEditor, |
| 434 | element as unknown as TElement, |
| 435 | ), |
| 436 | )} |
| 437 | style={getGridStyleForElement( |
| 438 | editor as unknown as PlateEditor, |
| 439 | element as unknown as TElement, |
| 440 | )} |
| 441 | ref={nodeRef} |
| 442 | onPointerEnter={showGutter} |
| 443 | onPointerLeave={hideGutter} |
| 444 | > |
| 445 | <div |
| 446 | className={cn( |
| 447 | "relative overflow-visible", |
| 448 | isContentHeightElement ? "h-fit" : "h-full", |
| 449 | isDragging && "opacity-50", |
| 450 | showHoverBorder && |
| 451 | "after:pointer-events-none after:absolute after:-inset-1", |
| 452 | showHoverBorder && |
| 453 | (isGutterActive || isAboutToDrag || isDragging |
| 454 | ? "after:border after:border-blue-400" |
| 455 | : "hover:after:border hover:after:border-blue-400"), |
| 456 | getContainerTypes(editor).includes(element.type) |
| 457 | ? "group/container" |
| 458 | : "group", |
| 459 | )} |
| 460 | > |
| 461 | {!isInTable && !editor.dom.readOnly && ( |
| 462 | <Gutter |
| 463 | active={isGutterActive || isAboutToDrag || isDragging} |
| 464 | orientation={orientation} |
| 465 | onPointerEnter={showGutter} |
| 466 | onPointerLeave={hideGutter} |
| 467 | > |
| 468 | <div |
| 469 | className={cn( |
| 470 | "slate-blockToolbarWrapper", |
| 471 | "pointer-events-auto flex", |
| 472 | orientation === "horizontal" |
| 473 | ? "h-6 w-full justify-center" |
| 474 | : "h-[1.5em]", |
| 475 | isType(editor, element, [ |
| 476 | KEYS.h1, |
| 477 | KEYS.h2, |
| 478 | KEYS.h3, |
| 479 | KEYS.h4, |
| 480 | KEYS.h5, |
| 481 | ]) && |
| 482 | orientation === "vertical" && |
| 483 | "h-[1.3em]", |
| 484 | isInColumn && orientation === "vertical" && "", |
| 485 | )} |
| 486 | > |
| 487 | <div |
| 488 | className={cn( |
| 489 | "slate-blockToolbar", |
| 490 | "pointer-events-auto flex items-center", |
| 491 | orientation === "horizontal" ? "mb-1" : "mr-1", |
| 492 | isInColumn && orientation === "vertical" && "mr-1.5", |
| 493 | )} |
| 494 | > |
| 495 | <Button |
| 496 | ref={handleRef} |
| 497 | variant="ghost" |
| 498 | className={cn( |
| 499 | "relative border-0 bg-transparent p-0 shadow-none", |
| 500 | "hover:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 active:bg-transparent", |
| 501 | orientation === "horizontal" |
| 502 | ? "-mb-1.5 h-5 w-8" |
| 503 | : "-mr-1.5 h-8 w-5", |
| 504 | )} |
| 505 | onFocus={showGutter} |
| 506 | onPointerDown={showGutter} |
| 507 | data-plate-prevent-deselect |
| 508 | > |
| 509 | <DragHandle |
| 510 | orientation={orientation} |
| 511 | isDragging={isDragging} |
| 512 | previewRef={previewRef} |
| 513 | resetPreview={resetPreview} |
| 514 | setPreviewTop={setPreviewTop} |
| 515 | /> |
| 516 | </Button> |
| 517 | </div> |
| 518 | </div> |
| 519 | </Gutter> |
| 520 | )} |
| 521 | |
| 522 | <div |
| 523 | ref={previewRef} |
| 524 | className={cn("pointer-events-none absolute left-0 hidden w-full")} |
| 525 | style={{ top: `${-previewTop}px` }} |
| 526 | contentEditable={false} |
| 527 | /> |
| 528 | |
| 529 | <div |
| 530 | className={cn( |
| 531 | "slate-blockWrapper", |
| 532 | isContentHeightElement ? "h-fit" : "h-full", |
| 533 | )} |
| 534 | onContextMenu={(event) => |
| 535 | editor |
| 536 | .getApi(BlockSelectionPlugin) |
| 537 | .blockSelection.addOnContextMenu({ element, event }) |
| 538 | } |
| 539 | > |
| 540 | <MemoizedChildren key={childrenRenderKey}> |
| 541 | {children} |
| 542 | </MemoizedChildren> |
| 543 | <DropLine /> |
| 544 | </div> |
| 545 | </div> |
| 546 | </div> |
| 547 | ); |
| 548 | } |
| 549 | |
| 550 | function Gutter({ |
| 551 | active = false, |
| 552 | children, |
| 553 | className, |
| 554 | orientation = "vertical", |
| 555 | ...props |
| 556 | }: React.ComponentProps<"div"> & { |
| 557 | active?: boolean; |
| 558 | orientation?: "horizontal" | "vertical"; |
| 559 | }) { |
| 560 | const editor = useEditorRef(); |
| 561 | const element = useElement(); |
| 562 | const path = usePath(); |
| 563 | const isSelectionAreaVisible = usePluginOption( |
| 564 | BlockSelectionPlugin, |
| 565 | "isSelectionAreaVisible", |
| 566 | ); |
| 567 | const isTouchDevice = useIsTouchDevice(); |
| 568 | const isEditorFocused = useFocused(); |
| 569 | |
| 570 | const selected = useSelected(); |
| 571 | |
| 572 | // Check if the editor's selection/cursor is within this element |
| 573 | const isFocusedWithin = useEditorSelector(() => { |
| 574 | if (!isEditorFocused || !path) return false; |
| 575 | |
| 576 | const selection = editor.selection; |
| 577 | if (!selection) return false; |
| 578 | |
| 579 | // Get the block at the selection focus point |
| 580 | const focusBlock = editor.api.block({ at: selection.focus }); |
| 581 | if (!focusBlock) return false; |
| 582 | |
| 583 | const [, focusPath] = focusBlock; |
| 584 | |
| 585 | // Check if the focus path starts with or is equal to this element's path |
| 586 | // This means the cursor is within this element |
| 587 | // A path is an ancestor if it's a prefix of the focus path |
| 588 | return ( |
| 589 | PathApi.equals(path, focusPath) || |
| 590 | (focusPath.length > path.length && |
| 591 | focusPath.slice(0, path.length).every((p, i) => p === path[i])) |
| 592 | ); |
| 593 | }, [isEditorFocused, path]); |
| 594 | |
| 595 | const isNodeType = (keys: string[] | string) => isType(editor, element, keys); |
| 596 | const isChildOfColumnItem = React.useMemo(() => { |
| 597 | if (!path || path.length === 0) return false; |
| 598 | try { |
| 599 | const parentNode = editor.api.node({ at: PathApi.parent(path) }); |
| 600 | return parentNode?.[0]?.type === "column"; |
| 601 | } catch { |
| 602 | return false; |
| 603 | } |
| 604 | }, [editor, path]); |
| 605 | const isInColumn = path.length === 3 || isChildOfColumnItem; |
| 606 | |
| 607 | return ( |
| 608 | <div |
| 609 | {...props} |
| 610 | className={cn( |
| 611 | "slate-gutterLeft", |
| 612 | "pointer-events-none absolute z-999999 flex cursor-text transition-opacity duration-100", |
| 613 | // On touch devices, show when editor is focused and selection is within this element |
| 614 | // On desktop, show on hover |
| 615 | isTouchDevice |
| 616 | ? isFocusedWithin && !isSelectionAreaVisible |
| 617 | ? "opacity-100" |
| 618 | : "opacity-0" |
| 619 | : "hover:opacity-100 sm:opacity-0", |
| 620 | orientation === "horizontal" |
| 621 | ? "top-0 left-1/2 -translate-x-1/2 -translate-y-1/2" |
| 622 | : "top-0 left-0 h-full -translate-x-full", |
| 623 | // Desktop hover behavior |
| 624 | !isTouchDevice && |
| 625 | (getContainerTypes(editor).includes(element.type) |
| 626 | ? "group-hover/container:opacity-100" |
| 627 | : "group-hover:opacity-100"), |
| 628 | isSelectionAreaVisible && "hidden", |
| 629 | // On desktop, hide when not selected (unless hovering) |
| 630 | !isTouchDevice && !selected && "opacity-0", |
| 631 | active && !isSelectionAreaVisible && "opacity-100 sm:opacity-100", |
| 632 | // Vertical orientation specific styles |
| 633 | orientation === "vertical" && "w-8 justify-end", |
| 634 | orientation === "vertical" && [ |
| 635 | isNodeType(KEYS.h1) && "pb-1 text-[1.875em]", |
| 636 | isNodeType(KEYS.h2) && "pb-1 text-[1.5em]", |
| 637 | isNodeType(KEYS.h3) && "pt-0.5 pb-1 text-[1.25em]", |
| 638 | isNodeType([KEYS.h4, KEYS.h5]) && "pt-1 pb-0 text-[1.1em]", |
| 639 | isNodeType(KEYS.h6) && "pb-0", |
| 640 | isNodeType(KEYS.p) && "pt-1 pb-0", |
| 641 | isNodeType(KEYS.blockquote) && "pb-0", |
| 642 | isNodeType(KEYS.codeBlock) && "pt-6 pb-0", |
| 643 | isNodeType([ |
| 644 | KEYS.img, |
| 645 | KEYS.mediaEmbed, |
| 646 | KEYS.excalidraw, |
| 647 | KEYS.toggle, |
| 648 | KEYS.column, |
| 649 | ]) && "py-0", |
| 650 | isNodeType([KEYS.placeholder, KEYS.table]) && "pt-3 pb-0", |
| 651 | isInColumn && "items-center", |
| 652 | ], |
| 653 | className, |
| 654 | )} |
| 655 | contentEditable={false} |
| 656 | > |
| 657 | {children} |
| 658 | </div> |
| 659 | ); |
| 660 | } |
| 661 | |
| 662 | const DragHandle = React.memo(function DragHandle({ |
| 663 | orientation = "vertical", |
| 664 | isDragging, |
| 665 | previewRef, |
| 666 | resetPreview, |
| 667 | setPreviewTop, |
| 668 | }: { |
| 669 | orientation?: "horizontal" | "vertical"; |
| 670 | isDragging: boolean; |
| 671 | previewRef: React.RefObject<HTMLDivElement | null>; |
| 672 | resetPreview: () => void; |
| 673 | setPreviewTop: (top: number) => void; |
| 674 | }) { |
| 675 | const editor = useEditorRef(); |
| 676 | const element = useElement(); |
| 677 | |
| 678 | // Track if a drag actually happened (vs just a click) |
| 679 | const dragStartedRef = React.useRef(false); |
| 680 | const pendingBlocksRef = React.useRef<TElement[]>([]); |
| 681 | |
| 682 | React.useEffect(() => { |
| 683 | if (isDragging) { |
| 684 | dragStartedRef.current = true; |
| 685 | } |
| 686 | }, [isDragging]); |
| 687 | |
| 688 | const startDrag = (e: React.MouseEvent | React.TouchEvent) => { |
| 689 | resetPreview(); |
| 690 | dragStartedRef.current = false; |
| 691 | |
| 692 | // For mouse events, check button |
| 693 | if ("button" in e && (e.button !== 0 || e.shiftKey)) return; |
| 694 | |
| 695 | // For touch events, prevent default to avoid text selection |
| 696 | // But for mouse events, we must NOT prevent default or stop propagation |
| 697 | // because react-dnd needs these events to bubble up to the handleRef |
| 698 | if ("touches" in e) { |
| 699 | e.preventDefault(); |
| 700 | } |
| 701 | |
| 702 | const blockSelection = editor |
| 703 | .getApi(BlockSelectionPlugin) |
| 704 | .blockSelection.getNodes({ sort: true }); |
| 705 | |
| 706 | let selectionNodes = |
| 707 | blockSelection.length > 0 |
| 708 | ? blockSelection |
| 709 | : editor.api.blocks({ mode: "highest" }); |
| 710 | |
| 711 | // If current block is not in selection, use it as the starting point |
| 712 | if (!selectionNodes.some(([node]) => node.id === element.id)) { |
| 713 | selectionNodes = [[element, editor.api.findPath(element)!]]; |
| 714 | } |
| 715 | |
| 716 | // Process selection nodes to include list children |
| 717 | const blocks = expandListItemsWithChildren(editor, selectionNodes).map( |
| 718 | ([node]) => node, |
| 719 | ); |
| 720 | |
| 721 | // Store blocks for potential selection on mouse up |
| 722 | pendingBlocksRef.current = blocks; |
| 723 | |
| 724 | if (blockSelection.length === 0) { |
| 725 | editor.tf.blur(); |
| 726 | editor.tf.collapse(); |
| 727 | } |
| 728 | |
| 729 | // Only prepare the preview elements, don't set selection yet |
| 730 | const elements = createDragPreviewElements(editor, blocks); |
| 731 | previewRef.current?.append(...elements); |
| 732 | previewRef.current?.classList.remove("hidden"); |
| 733 | previewRef.current?.classList.add("opacity-0"); |
| 734 | editor.setOption(DndPlugin, "multiplePreviewRef", previewRef); |
| 735 | |
| 736 | // Note: We intentionally do NOT set block selection here |
| 737 | // Selection will happen on mouse up if it wasn't a drag |
| 738 | }; |
| 739 | |
| 740 | const handleMouseDown = (e: React.MouseEvent) => { |
| 741 | startDrag(e); |
| 742 | }; |
| 743 | |
| 744 | const handleTouchStart = (e: React.TouchEvent) => { |
| 745 | startDrag(e); |
| 746 | }; |
| 747 | |
| 748 | const endDrag = () => { |
| 749 | resetPreview(); |
| 750 | |
| 751 | // Only select blocks on pointer up when this interaction stayed a click. |
| 752 | if (!dragStartedRef.current && pendingBlocksRef.current.length > 0) { |
| 753 | // Set block selection now (on mouse up) instead of mouse down |
| 754 | editor |
| 755 | .getApi(BlockSelectionPlugin) |
| 756 | .blockSelection.set( |
| 757 | pendingBlocksRef.current.map((block) => block.id as string), |
| 758 | ); |
| 759 | |
| 760 | // Focus the block selection to show toolbar |
| 761 | editor.getApi(BlockSelectionPlugin).blockSelection.focus(); |
| 762 | } |
| 763 | |
| 764 | // Clear pending blocks |
| 765 | dragStartedRef.current = false; |
| 766 | pendingBlocksRef.current = []; |
| 767 | }; |
| 768 | |
| 769 | const handleMouseUp = () => { |
| 770 | endDrag(); |
| 771 | }; |
| 772 | |
| 773 | const handleTouchEnd = (e: React.TouchEvent) => { |
| 774 | e.preventDefault(); |
| 775 | e.stopPropagation(); |
| 776 | endDrag(); |
| 777 | }; |
| 778 | |
| 779 | const handleMouseEnter = () => { |
| 780 | if (isDragging) return; |
| 781 | |
| 782 | const blockSelection = editor |
| 783 | .getApi(BlockSelectionPlugin) |
| 784 | .blockSelection.getNodes({ sort: true }); |
| 785 | |
| 786 | let selectedBlocks = |
| 787 | blockSelection.length > 0 |
| 788 | ? blockSelection |
| 789 | : editor.api.blocks({ mode: "highest" }); |
| 790 | |
| 791 | // If current block is not in selection, use it as the starting point |
| 792 | if (!selectedBlocks.some(([node]) => node.id === element.id)) { |
| 793 | selectedBlocks = [[element, editor.api.findPath(element)!]]; |
| 794 | } |
| 795 | |
| 796 | // Process selection to include list children |
| 797 | const processedBlocks = expandListItemsWithChildren(editor, selectedBlocks); |
| 798 | |
| 799 | const ids = processedBlocks.map((block) => block[0].id as string); |
| 800 | |
| 801 | if (ids.length > 1 && ids.includes(element.id as string)) { |
| 802 | const previewTop = calculatePreviewTop(editor, { |
| 803 | blocks: processedBlocks.map((block) => block[0]), |
| 804 | element, |
| 805 | }); |
| 806 | setPreviewTop(previewTop); |
| 807 | } else { |
| 808 | setPreviewTop(0); |
| 809 | } |
| 810 | }; |
| 811 | |
| 812 | return ( |
| 813 | <Tooltip delayDuration={1000}> |
| 814 | <TooltipTrigger asChild> |
| 815 | <div |
| 816 | className="relative flex size-full touch-none items-center justify-center" |
| 817 | onMouseDown={handleMouseDown} |
| 818 | onMouseUp={handleMouseUp} |
| 819 | onMouseEnter={handleMouseEnter} |
| 820 | onTouchStart={handleTouchStart} |
| 821 | onTouchEnd={handleTouchEnd} |
| 822 | role="button" |
| 823 | data-plate-prevent-deselect |
| 824 | > |
| 825 | <span |
| 826 | className={cn( |
| 827 | "pointer-events-none absolute flex items-center justify-center rounded-md", |
| 828 | "text-muted-foreground/90 drop-shadow-[0_1px_1px_rgba(0,0,0,0.45)]", |
| 829 | orientation === "horizontal" |
| 830 | ? "bottom-0 left-1/2 h-4 w-7 -translate-x-1/2" |
| 831 | : "top-1/2 right-0 h-7 w-4 -translate-y-1/2", |
| 832 | )} |
| 833 | > |
| 834 | {orientation === "horizontal" ? ( |
| 835 | <GripHorizontal |
| 836 | className="size-4 text-current" |
| 837 | data-ppt-ignore="true" |
| 838 | /> |
| 839 | ) : ( |
| 840 | <GripVertical |
| 841 | className="size-4 text-current" |
| 842 | data-ppt-ignore="true" |
| 843 | /> |
| 844 | )} |
| 845 | </span> |
| 846 | </div> |
| 847 | </TooltipTrigger> |
| 848 | <TooltipContent>Hold and drag to move, or click to edit</TooltipContent> |
| 849 | </Tooltip> |
| 850 | ); |
| 851 | }); |
| 852 | |
| 853 | const DropLine = React.memo(function DropLine({ |
| 854 | className, |
| 855 | }: { |
| 856 | className?: string; |
| 857 | }) { |
| 858 | const { dropLine } = useDropLine(); |
| 859 | |
| 860 | if (!dropLine) return null; |
| 861 | |
| 862 | return ( |
| 863 | <motion.div |
| 864 | layout="position" |
| 865 | layoutId="presentation-dnd-drop-line" |
| 866 | transition={{ layout: { duration: 0.16, ease: "easeOut" } }} |
| 867 | className={cn( |
| 868 | "slate-dropLine", |
| 869 | "absolute rounded-full opacity-100 transition-opacity", |
| 870 | "bg-blue-500", |
| 871 | // Horizontal line styles for vertical drops |
| 872 | (dropLine === "top" || dropLine === "bottom") && "inset-x-0 h-0.5", |
| 873 | // Vertical line styles for horizontal drops |
| 874 | (dropLine === "left" || dropLine === "right") && "inset-y-0 w-0.5", |
| 875 | // Positioning |
| 876 | dropLine === "top" && "-top-px", |
| 877 | dropLine === "bottom" && "-bottom-px", |
| 878 | dropLine === "left" && "-left-px", |
| 879 | dropLine === "right" && "-right-px", |
| 880 | className, |
| 881 | )} |
| 882 | /> |
| 883 | ); |
| 884 | }); |
| 885 | |
| 886 | const createDragPreviewElements = ( |
| 887 | editor: PlateEditor, |
| 888 | blocks: TElement[], |
| 889 | ): HTMLElement[] => { |
| 890 | const elements: HTMLElement[] = []; |
| 891 | const ids: string[] = []; |
| 892 | |
| 893 | /** |
| 894 | * Remove data attributes from the element to avoid recognized as slate |
| 895 | * elements incorrectly. |
| 896 | */ |
| 897 | const removeDataAttributes = (element: HTMLElement) => { |
| 898 | Array.from(element.attributes).forEach((attr) => { |
| 899 | if ( |
| 900 | attr.name.startsWith("data-slate") || |
| 901 | attr.name.startsWith("data-block-id") |
| 902 | ) { |
| 903 | element.removeAttribute(attr.name); |
| 904 | } |
| 905 | }); |
| 906 | |
| 907 | Array.from(element.children).forEach((child) => { |
| 908 | removeDataAttributes(child as HTMLElement); |
| 909 | }); |
| 910 | }; |
| 911 | |
| 912 | const resolveElement = (node: TElement, index: number) => { |
| 913 | const domNode = editor.api.toDOMNode(node)!; |
| 914 | const newDomNode = domNode.cloneNode(true) as HTMLElement; |
| 915 | |
| 916 | // Apply visual compensation for horizontal scroll |
| 917 | const applyScrollCompensation = ( |
| 918 | original: Element, |
| 919 | cloned: HTMLElement, |
| 920 | ) => { |
| 921 | const scrollLeft = original.scrollLeft; |
| 922 | |
| 923 | if (scrollLeft > 0) { |
| 924 | // Create a wrapper to handle the scroll offset |
| 925 | const scrollWrapper = document.createElement("div"); |
| 926 | scrollWrapper.style.overflow = "hidden"; |
| 927 | scrollWrapper.style.width = `${original.clientWidth}px`; |
| 928 | |
| 929 | // Create inner container with the full content |
| 930 | const innerContainer = document.createElement("div"); |
| 931 | innerContainer.style.transform = `translateX(-${scrollLeft}px)`; |
| 932 | innerContainer.style.width = `${original.scrollWidth}px`; |
| 933 | |
| 934 | // Move all children to the inner container |
| 935 | while (cloned.firstChild) { |
| 936 | innerContainer.append(cloned.firstChild); |
| 937 | } |
| 938 | |
| 939 | // Apply the original element's styles to maintain appearance |
| 940 | const originalStyles = window.getComputedStyle(original); |
| 941 | cloned.style.padding = "0"; |
| 942 | innerContainer.style.padding = originalStyles.padding; |
| 943 | |
| 944 | scrollWrapper.append(innerContainer); |
| 945 | cloned.append(scrollWrapper); |
| 946 | } |
| 947 | }; |
| 948 | |
| 949 | applyScrollCompensation(domNode, newDomNode); |
| 950 | |
| 951 | ids.push(node.id as string); |
| 952 | const wrapper = document.createElement("div"); |
| 953 | wrapper.append(newDomNode); |
| 954 | wrapper.style.display = "flow-root"; |
| 955 | |
| 956 | const lastDomNode = blocks[index - 1]; |
| 957 | |
| 958 | if (lastDomNode) { |
| 959 | const lastDomNodeRect = editor.api |
| 960 | .toDOMNode(lastDomNode)! |
| 961 | .parentElement!.getBoundingClientRect(); |
| 962 | |
| 963 | const domNodeRect = domNode.parentElement!.getBoundingClientRect(); |
| 964 | |
| 965 | const distance = domNodeRect.top - lastDomNodeRect.bottom; |
| 966 | |
| 967 | // Check if the two elements are adjacent (touching each other) |
| 968 | if (distance > 15) { |
| 969 | wrapper.style.marginTop = `${distance}px`; |
| 970 | } |
| 971 | } |
| 972 | |
| 973 | removeDataAttributes(newDomNode); |
| 974 | elements.push(wrapper); |
| 975 | }; |
| 976 | |
| 977 | blocks.forEach((node, index) => resolveElement(node, index)); |
| 978 | |
| 979 | editor.setOption(DndPlugin, "draggingId", ids); |
| 980 | |
| 981 | return elements; |
| 982 | }; |
| 983 | |
| 984 | const calculatePreviewTop = ( |
| 985 | editor: PlateEditor, |
| 986 | { |
| 987 | blocks, |
| 988 | element, |
| 989 | }: { |
| 990 | blocks: TElement[]; |
| 991 | element: TElement; |
| 992 | }, |
| 993 | ): number => { |
| 994 | const child = editor.api.toDOMNode(element)!; |
| 995 | const editable = editor.api.toDOMNode(editor)!; |
| 996 | const firstSelectedChild = blocks[0]!; |
| 997 | |
| 998 | const firstDomNode = editor.api.toDOMNode(firstSelectedChild)!; |
| 999 | // Get editor's top padding |
| 1000 | const editorPaddingTop = Number( |
| 1001 | window.getComputedStyle(editable).paddingTop.replace("px", ""), |
| 1002 | ); |
| 1003 | |
| 1004 | // Calculate distance from first selected node to editor top |
| 1005 | const firstNodeToEditorDistance = |
| 1006 | firstDomNode.getBoundingClientRect().top - |
| 1007 | editable.getBoundingClientRect().top - |
| 1008 | editorPaddingTop; |
| 1009 | |
| 1010 | // Get margin top of first selected node |
| 1011 | const firstMarginTopString = window.getComputedStyle(firstDomNode).marginTop; |
| 1012 | const marginTop = Number(firstMarginTopString.replace("px", "")); |
| 1013 | |
| 1014 | // Calculate distance from current node to editor top |
| 1015 | const currentToEditorDistance = |
| 1016 | child.getBoundingClientRect().top - |
| 1017 | editable.getBoundingClientRect().top - |
| 1018 | editorPaddingTop; |
| 1019 | |
| 1020 | const currentMarginTopString = window.getComputedStyle(child).marginTop; |
| 1021 | const currentMarginTop = Number(currentMarginTopString.replace("px", "")); |
| 1022 | |
| 1023 | const previewElementsTopDistance = |
| 1024 | currentToEditorDistance - |
| 1025 | firstNodeToEditorDistance + |
| 1026 | marginTop - |
| 1027 | currentMarginTop; |
| 1028 | |
| 1029 | return previewElementsTopDistance; |
| 1030 | }; |
| 1031 |