|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { |
| 4 | + closestCenter, |
| 5 | + DndContext, |
| 6 | + DragOverEvent, |
| 7 | + DragOverlay, |
| 8 | + DragStartEvent, |
| 9 | + KeyboardSensor, |
| 10 | + PointerSensor, |
| 11 | + useSensor, |
| 12 | +} from "@dnd-kit/core"; |
| 13 | +import { RowColumn } from "@/components/form-builder/canvas/sortable-row"; |
| 14 | +import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; |
| 15 | +import { Form } from "@/components/ui/form"; |
| 16 | +import { useFormBuilderStore } from "@/stores/form-builder-store"; |
| 17 | +import { useForm } from "react-hook-form"; |
| 18 | +import { useMemo, memo, useCallback, useState } from "react"; |
| 19 | +import { Viewports } from "@/types/form-builder.types"; |
| 20 | +import { FormComponentModel } from "@/models/FormComponent"; |
| 21 | +import { RenderEditorComponent } from "../helpers/render-editor-component"; |
| 22 | +// Memoize the empty state component |
| 23 | +const EmptyState = memo(() => ( |
| 24 | + <div className="p-6 text-center text-sm text-muted-foreground bg-black/5 rounded-lg max-w-md mx-auto border-dashed border-2 border-slate-300"> |
| 25 | + Please add a component to the form |
| 26 | + </div> |
| 27 | +)); |
| 28 | + |
| 29 | +EmptyState.displayName = "EmptyState"; |
| 30 | + |
| 31 | +const getGridRows = ( |
| 32 | + items: FormComponentModel[], |
| 33 | + viewport: Viewports |
| 34 | +): FormComponentModel[][] => { |
| 35 | + const rows: FormComponentModel[][] = []; |
| 36 | + let currentRow: FormComponentModel[] = []; |
| 37 | + let currentRowSpan = 0; |
| 38 | + |
| 39 | + items.forEach((item) => { |
| 40 | + const colSpan = +item.getField("properties.style.colSpan", viewport) || 12; |
| 41 | + |
| 42 | + // If adding this item would exceed 12 columns, start a new row |
| 43 | + if (currentRowSpan + colSpan > 12) { |
| 44 | + if (currentRow.length > 0) { |
| 45 | + rows.push([...currentRow]); |
| 46 | + } |
| 47 | + currentRow = [item]; |
| 48 | + currentRowSpan = colSpan; |
| 49 | + } else { |
| 50 | + currentRow.push(item); |
| 51 | + currentRowSpan += colSpan; |
| 52 | + } |
| 53 | + }); |
| 54 | + |
| 55 | + // Add the last row if it has items |
| 56 | + if (currentRow.length > 0) { |
| 57 | + rows.push(currentRow); |
| 58 | + } |
| 59 | + |
| 60 | + return rows; |
| 61 | +}; |
| 62 | + |
| 63 | +export function updateColSpans( |
| 64 | + updateItems: FormComponentModel[], |
| 65 | + viewport: Viewports |
| 66 | +): { id: string; span: number }[] { |
| 67 | + if (!updateItems.length) return []; |
| 68 | + |
| 69 | + const totalColumns = 12; |
| 70 | + const itemCount = updateItems.length; |
| 71 | + |
| 72 | + // Calculate base span and remainder |
| 73 | + const baseSpan = Math.floor(totalColumns / itemCount); |
| 74 | + const remainder = totalColumns % itemCount; |
| 75 | + |
| 76 | + const adjustedSpans: { id: string; span: number }[] = []; |
| 77 | + |
| 78 | + // Distribute spans equally, with remainder distributed to first few items |
| 79 | + updateItems.forEach((item, index) => { |
| 80 | + if (!item) return; |
| 81 | + // Add one extra column to the first 'remainder' items |
| 82 | + const span = index < remainder ? baseSpan + 1 : baseSpan; |
| 83 | + adjustedSpans.push({ id: item.id, span }); |
| 84 | + }); |
| 85 | + |
| 86 | + return adjustedSpans; |
| 87 | +} |
| 88 | + |
| 89 | +export default function GenerateCanvasGrid() { |
| 90 | + // Split store selectors to minimize re-renders |
| 91 | + const components = useFormBuilderStore((state) => state.components); |
| 92 | + const selectComponent = useFormBuilderStore((state) => state.selectComponent); |
| 93 | + const mode = useFormBuilderStore((state) => state.mode); |
| 94 | + const form = useForm(); |
| 95 | + const updateComponent = useFormBuilderStore((state) => state.updateComponent); |
| 96 | + const moveComponent = useFormBuilderStore((state) => state.moveComponent); |
| 97 | + const viewport = useFormBuilderStore((state) => state.viewport); |
| 98 | + const [activeId, setActiveId] = useState<string | null>(null); |
| 99 | + const activeIndex = activeId |
| 100 | + ? components.findIndex((component) => component.id === activeId) |
| 101 | + : -1; |
| 102 | + |
| 103 | + const gridRows = getGridRows(components, viewport); |
| 104 | + |
| 105 | + // Create sensors outside of callback |
| 106 | + const pointerSensor = useSensor(PointerSensor, { |
| 107 | + activationConstraint: { |
| 108 | + distance: 20, |
| 109 | + }, |
| 110 | + }); |
| 111 | + const keyboardSensor = useSensor(KeyboardSensor, { |
| 112 | + coordinateGetter: sortableKeyboardCoordinates, |
| 113 | + }); |
| 114 | + |
| 115 | + // Memoize sensors array |
| 116 | + const sensors = useMemo( |
| 117 | + () => [pointerSensor, keyboardSensor], |
| 118 | + [pointerSensor, keyboardSensor] |
| 119 | + ); |
| 120 | + |
| 121 | + const handleDragStart = useCallback( |
| 122 | + (event: DragStartEvent) => { |
| 123 | + selectComponent(null); |
| 124 | + setActiveId(event.active.id as string); |
| 125 | + }, |
| 126 | + [selectComponent] |
| 127 | + ); |
| 128 | + |
| 129 | + |
| 130 | + // Memoize drag end handler |
| 131 | + const handleDragEnd = (event: any) => { |
| 132 | + const { active, over } = event; |
| 133 | + |
| 134 | + if (!over) { |
| 135 | + return; |
| 136 | + } |
| 137 | + |
| 138 | + const position = over.data.current.position; |
| 139 | + const activeIndex = active.data.current.index; |
| 140 | + const overIndex = over.data.current.index; |
| 141 | + |
| 142 | + if ( |
| 143 | + (activeIndex === overIndex && |
| 144 | + (position === "left" || position === "right")) || |
| 145 | + // Or the diff between active and over is 1 |
| 146 | + (activeIndex - overIndex === 1 && position === "bottom") || |
| 147 | + (overIndex - activeIndex === 1 && position === "top") |
| 148 | + ) { |
| 149 | + return; |
| 150 | + } |
| 151 | + |
| 152 | + const activeComponent = active.data.current.component; |
| 153 | + const overComponent = over.data.current.component; |
| 154 | + |
| 155 | + if (activeComponent && overComponent) { |
| 156 | + const overRowItems = |
| 157 | + gridRows.find((row) => |
| 158 | + row.some((item) => item.id === over.data.current?.component.id) |
| 159 | + ) || []; |
| 160 | + |
| 161 | + const overRowFirstItemIndex = components.findIndex( |
| 162 | + (component) => component.id === overRowItems[0].id |
| 163 | + ); |
| 164 | + |
| 165 | + const overRowLastItemIndex = components.findIndex( |
| 166 | + (component) => component.id === overRowItems[overRowItems.length - 1].id |
| 167 | + ); |
| 168 | + |
| 169 | + let activeRowItems = |
| 170 | + gridRows.find((row) => |
| 171 | + row.some((item) => item.id === active.data.current?.component.id) |
| 172 | + ) || []; |
| 173 | + |
| 174 | + let draggingInSameRow = overRowItems === activeRowItems; |
| 175 | + |
| 176 | + // Don´t update the spans if the component is being dragged in the same row |
| 177 | + activeRowItems = activeRowItems.filter( |
| 178 | + (item) => item.id !== activeComponent.id |
| 179 | + ); |
| 180 | + let updatedOverItems = []; |
| 181 | + |
| 182 | + if (position === "top" || position === "bottom") { |
| 183 | + updatedOverItems = updateColSpans([activeComponent], viewport); |
| 184 | + } else { |
| 185 | + updatedOverItems = updateColSpans( |
| 186 | + [...overRowItems, activeComponent], |
| 187 | + viewport |
| 188 | + ); |
| 189 | + } |
| 190 | + |
| 191 | + if ( |
| 192 | + (!draggingInSameRow && (position === "left" || position === "right")) || |
| 193 | + position === "top" || |
| 194 | + position === "bottom" |
| 195 | + ) { |
| 196 | + updatedOverItems.forEach((item) => { |
| 197 | + updateComponent( |
| 198 | + item.id, |
| 199 | + "properties.style.colSpan", |
| 200 | + `${item.span}`, |
| 201 | + false |
| 202 | + ); |
| 203 | + }); |
| 204 | + |
| 205 | + const updatedActiveItems = updateColSpans( |
| 206 | + [...activeRowItems], |
| 207 | + viewport |
| 208 | + ); |
| 209 | + |
| 210 | + updatedActiveItems.forEach((item) => { |
| 211 | + updateComponent( |
| 212 | + item.id, |
| 213 | + "properties.style.colSpan", |
| 214 | + `${item.span}`, |
| 215 | + false |
| 216 | + ); |
| 217 | + }); |
| 218 | + } |
| 219 | + |
| 220 | + const oldIndex = active.data.current.index; |
| 221 | + let newIndex = |
| 222 | + position === "left" |
| 223 | + ? overIndex |
| 224 | + : activeIndex < overIndex |
| 225 | + ? overIndex |
| 226 | + : overIndex + 1; |
| 227 | + |
| 228 | + if (position === "top") { |
| 229 | + newIndex = activeIndex < overIndex ? overRowFirstItemIndex - 1 : overRowFirstItemIndex; |
| 230 | + } |
| 231 | + |
| 232 | + if (position === "bottom") { |
| 233 | + newIndex = activeIndex < overIndex ? overRowLastItemIndex : overRowLastItemIndex + 1; |
| 234 | + } |
| 235 | + |
| 236 | + moveComponent(oldIndex, newIndex); |
| 237 | + } |
| 238 | + setActiveId(null); |
| 239 | + }; |
| 240 | + |
| 241 | + if (components.length === 0) { |
| 242 | + return <EmptyState />; |
| 243 | + } |
| 244 | + |
| 245 | + const handleClick = (event: React.MouseEvent<HTMLDivElement>) => { |
| 246 | + selectComponent(null); |
| 247 | + }; |
| 248 | + |
| 249 | + const activeComponent = activeId |
| 250 | + ? components.find((component) => component.id === activeId) |
| 251 | + : null; |
| 252 | + |
| 253 | + return ( |
| 254 | + <div |
| 255 | + className={`grid grid-cols-12 gap-4`} |
| 256 | + onClick={mode === "editor" ? handleClick : undefined} |
| 257 | + > |
| 258 | + <Form {...form}> |
| 259 | + <DndContext |
| 260 | + sensors={sensors} |
| 261 | + collisionDetection={closestCenter} |
| 262 | + onDragEnd={handleDragEnd} |
| 263 | + onDragStart={handleDragStart} |
| 264 | + > |
| 265 | + {components.map((component, index) => ( |
| 266 | + <RowColumn |
| 267 | + key={component.id} |
| 268 | + component={component} |
| 269 | + index={index} |
| 270 | + form={form} |
| 271 | + /> |
| 272 | + ))} |
| 273 | + <DragOverlay className="bg-white/80 shadow outline-1 outline-offset-6 outline-indigo-400 rounded-xs overflow-hidden"> |
| 274 | + {activeComponent && ( |
| 275 | + <RenderEditorComponent |
| 276 | + key={activeComponent.id} |
| 277 | + component={activeComponent} |
| 278 | + form={form} |
| 279 | + /> |
| 280 | + )} |
| 281 | + </DragOverlay> |
| 282 | + </DndContext> |
| 283 | + </Form> |
| 284 | + </div> |
| 285 | + ); |
| 286 | +} |
0 commit comments