| 1 | import { DRAG_ITEM_BLOCK } from "@platejs/dnd"; |
| 2 | import { ImagePlugin } from "@platejs/media/react"; |
| 3 | import { type TElement } from "platejs"; |
| 4 | import { useEditorRef, type PlateEditor } from "platejs/react"; |
| 5 | import { useEffect, useRef } from "react"; |
| 6 | import { useDrop } from "react-dnd"; |
| 7 | |
| 8 | import { type LayoutType } from "@/components/notebook/presentation/utils/parser"; |
| 9 | import { cn } from "@/lib/utils"; |
| 10 | import { usePresentationState } from "@/states/presentation-state"; |
| 11 | import { isChartType } from "../../lib"; |
| 12 | |
| 13 | const canDropElement = (item: { element: TElement }) => { |
| 14 | if (!item?.element) return false; |
| 15 | return ( |
| 16 | item.element.type === ImagePlugin.key || isChartType(item.element.type) |
| 17 | ); |
| 18 | }; |
| 19 | |
| 20 | function removeNodeById(editor: PlateEditor, element: TElement) { |
| 21 | const path = editor.api.findPath(element); |
| 22 | |
| 23 | if (!path) return; |
| 24 | editor.tf.removeNodes({ at: path }); |
| 25 | return element; |
| 26 | } |
| 27 | |
| 28 | export default function LayoutImageDrop({ slideId }: { slideId: string }) { |
| 29 | // Create drop zones for top, left, and right |
| 30 | const topRef = useRef<HTMLDivElement>(null); |
| 31 | const leftRef = useRef<HTMLDivElement>(null); |
| 32 | const rightRef = useRef<HTMLDivElement>(null); |
| 33 | const editor = useEditorRef(); |
| 34 | const isReorderingSlides = usePresentationState((s) => s.isReorderingSlides); |
| 35 | |
| 36 | // Check if element is image or chart |
| 37 | |
| 38 | const handleDrop = (item: { element: TElement }, layoutType: LayoutType) => { |
| 39 | if (!item?.element) return; |
| 40 | |
| 41 | const elementType = item.element.type; |
| 42 | const isImage = elementType === ImagePlugin.key; |
| 43 | const isChart = isChartType(elementType); |
| 44 | |
| 45 | if (!isImage && !isChart) return; |
| 46 | |
| 47 | // Get the current slides state |
| 48 | const { slides, setSlides, setCurrentSlideId } = |
| 49 | usePresentationState.getState(); |
| 50 | |
| 51 | if (isImage) { |
| 52 | // Handle image drop |
| 53 | let imageUrl = item.element.url as string; |
| 54 | let imageQuery = item.element.query as string; |
| 55 | |
| 56 | // Check if the image is from the editor and needs to be removed |
| 57 | const element = removeNodeById(editor, item.element); |
| 58 | if (element?.url) imageUrl = element.url as string; |
| 59 | if (element?.query) imageQuery = element.query as string; |
| 60 | |
| 61 | // Update the slides array with the new root image and layout type |
| 62 | // When dropping an image, we keep any existing chart data but set the image URL |
| 63 | // This allows restoring the chart later if needed |
| 64 | const updatedSlides = slides.map((slide) => { |
| 65 | if (slide.id === slideId) { |
| 66 | const existingRootImage = slide.rootImage; |
| 67 | return { |
| 68 | ...slide, |
| 69 | rootImage: { |
| 70 | ...existingRootImage, |
| 71 | url: imageUrl, |
| 72 | query: imageQuery, |
| 73 | imageSource: undefined, |
| 74 | embedType: undefined, |
| 75 | // Keep chartType, chartData, chartOptions but they won't be shown since url is set |
| 76 | // Reset size when layout changes so default dimensions are used |
| 77 | size: undefined, |
| 78 | }, |
| 79 | layoutType: layoutType, |
| 80 | }; |
| 81 | } |
| 82 | return slide; |
| 83 | }); |
| 84 | |
| 85 | setSlides(updatedSlides); |
| 86 | setCurrentSlideId(slideId); |
| 87 | } else if (isChart) { |
| 88 | // Handle chart drop |
| 89 | const chartType = item.element.type; |
| 90 | const chartData = (item.element as unknown as { data?: unknown }).data; |
| 91 | const chartOptions = { |
| 92 | variant: (item.element as unknown as { variant?: string }).variant, |
| 93 | disableAnimation: true, |
| 94 | }; |
| 95 | |
| 96 | // Remove the chart element from its original position |
| 97 | removeNodeById(editor, item.element); |
| 98 | |
| 99 | // Update the slides array with the chart data |
| 100 | const updatedSlides = slides.map((slide) => { |
| 101 | if (slide.id === slideId) { |
| 102 | return { |
| 103 | ...slide, |
| 104 | rootImage: { |
| 105 | ...slide.rootImage, |
| 106 | chartType, |
| 107 | chartData, |
| 108 | chartOptions, |
| 109 | // Clear any existing image/embed data |
| 110 | url: undefined, |
| 111 | embedType: undefined, |
| 112 | imageSource: undefined, |
| 113 | query: slide.rootImage?.query ?? "", |
| 114 | // Reset size when layout changes so default dimensions are used |
| 115 | size: undefined, |
| 116 | }, |
| 117 | layoutType: layoutType, |
| 118 | }; |
| 119 | } |
| 120 | return slide; |
| 121 | }); |
| 122 | |
| 123 | setSlides(updatedSlides); |
| 124 | setCurrentSlideId(slideId); |
| 125 | } |
| 126 | }; |
| 127 | |
| 128 | // Setup drop zones |
| 129 | const [{ isTopOver }, dropTop] = useDrop({ |
| 130 | accept: [DRAG_ITEM_BLOCK], |
| 131 | canDrop: (item: { element: TElement }) => |
| 132 | !isReorderingSlides && canDropElement(item), |
| 133 | drop: (item) => { |
| 134 | handleDrop(item, "vertical"); |
| 135 | return { droppedInLayoutZone: true }; |
| 136 | }, |
| 137 | collect: (monitor) => { |
| 138 | return { |
| 139 | isTopOver: monitor.isOver() && monitor.canDrop(), |
| 140 | }; |
| 141 | }, |
| 142 | }); |
| 143 | |
| 144 | const [{ isLeftOver }, dropLeft] = useDrop({ |
| 145 | accept: [DRAG_ITEM_BLOCK], |
| 146 | canDrop: (item: { element: TElement }) => |
| 147 | !isReorderingSlides && canDropElement(item), |
| 148 | drop: (item) => { |
| 149 | handleDrop(item, "left"); |
| 150 | return { droppedInLayoutZone: true }; |
| 151 | }, |
| 152 | collect: (monitor) => ({ |
| 153 | isLeftOver: monitor.isOver() && monitor.canDrop(), |
| 154 | }), |
| 155 | }); |
| 156 | |
| 157 | const [{ isRightOver }, dropRight] = useDrop({ |
| 158 | accept: [DRAG_ITEM_BLOCK], |
| 159 | canDrop: (item: { element: TElement }) => |
| 160 | !isReorderingSlides && canDropElement(item), |
| 161 | drop: (item) => { |
| 162 | handleDrop(item, "right"); |
| 163 | return { droppedInLayoutZone: true }; |
| 164 | }, |
| 165 | collect: (monitor) => ({ |
| 166 | isRightOver: monitor.isOver() && monitor.canDrop(), |
| 167 | }), |
| 168 | }); |
| 169 | useEffect(() => { |
| 170 | dropTop(topRef); |
| 171 | dropLeft(leftRef); |
| 172 | dropRight(rightRef); |
| 173 | }, [dropLeft, dropRight, dropTop]); |
| 174 | |
| 175 | return ( |
| 176 | <> |
| 177 | {/* Top drop zone */} |
| 178 | <div |
| 179 | ref={topRef} |
| 180 | className={cn( |
| 181 | "absolute top-0 right-0 left-0 z-99999 h-12", |
| 182 | isTopOver ? "bg-primary/20" : "bg-transparent", |
| 183 | "transition-colors duration-200", |
| 184 | )} |
| 185 | /> |
| 186 | |
| 187 | {/* Left drop zone */} |
| 188 | <div |
| 189 | ref={leftRef} |
| 190 | className={cn( |
| 191 | "absolute top-16 bottom-0 left-0 z-99999 w-8", |
| 192 | isLeftOver ? "bg-primary/20" : "bg-transparent", |
| 193 | "transition-colors duration-200", |
| 194 | )} |
| 195 | /> |
| 196 | |
| 197 | {/* Right drop zone */} |
| 198 | <div |
| 199 | ref={rightRef} |
| 200 | className={cn( |
| 201 | "absolute top-16 right-0 bottom-0 z-99999 w-8", |
| 202 | isRightOver ? "bg-primary/20" : "bg-transparent", |
| 203 | "transition-colors duration-200", |
| 204 | )} |
| 205 | /> |
| 206 | </> |
| 207 | ); |
| 208 | } |
| 209 |