返回 presentation-ai
block-draggable.tsx
根目录 / src / components / plate / ui / block-draggable.tsx
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
1031 lines Plain Text