| 1 |
import { ref, computed } from 'vue' |
| 2 |
import { storeToRefs } from 'pinia' |
| 3 |
import type { Field } from '@/types' |
| 4 |
import { useFormBuilder } from '@/stores/useFormBuilder' |
| 5 |
import { duplicateFieldForStore } from '@/utils/fieldUtils' |
| 6 |
import { useLabels } from '@/composables/useLabels' |
| 7 |
|
| 8 |
interface DropIndicator { |
| 9 |
fieldIndex: number |
| 10 |
position: 'left' | 'right' |
| 11 |
} |
| 12 |
|
| 13 |
interface FieldRow { |
| 14 |
rowIndex: number |
| 15 |
fields: Field[] |
| 16 |
} |
| 17 |
|
| 18 |
export function useFormBuilderDraggable() { |
| 19 |
const formBuilderStore = useFormBuilder() |
| 20 |
const { selectedField, fields } = storeToRefs(formBuilderStore) |
| 21 |
const { getLabel } = useLabels() |
| 22 |
|
| 23 |
// Drag state |
| 24 |
const isDragging = ref(false) |
| 25 |
const isDraggingRow = ref(false) |
| 26 |
const draggedField = ref<Field | null>(null) |
| 27 |
const draggedRow = ref<FieldRow | null>(null) |
| 28 |
const draggedFromRow = ref<FieldRow | null>(null) |
| 29 |
const dropIndicator = ref<DropIndicator | null>(null) |
| 30 |
const dropZoneRowIndex = ref<number | null>(null) |
| 31 |
const hoveredRowIndex = ref<number | null>(null) |
| 32 |
|
| 33 |
// Field rows computed |
| 34 |
const fieldRows = computed(() => { |
| 35 |
const rows: FieldRow[] = [] |
| 36 |
const rowMap = new Map<number, Field[]>() |
| 37 |
|
| 38 |
formBuilderStore.fields.forEach((field) => { |
| 39 |
const rowIdx = field.rowIndex ?? 0 |
| 40 |
if (!rowMap.has(rowIdx)) { |
| 41 |
rowMap.set(rowIdx, []) |
| 42 |
} |
| 43 |
rowMap.get(rowIdx)!.push(field) |
| 44 |
}) |
| 45 |
|
| 46 |
const sortedRowIndexes = Array.from(rowMap.keys()).sort((a, b) => a - b) |
| 47 |
|
| 48 |
sortedRowIndexes.forEach((rowIdx) => { |
| 49 |
const fieldsInRow = rowMap.get(rowIdx)! |
| 50 |
fieldsInRow.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0)) |
| 51 |
rows.push({ |
| 52 |
rowIndex: rowIdx, |
| 53 |
fields: fieldsInRow, |
| 54 |
}) |
| 55 |
}) |
| 56 |
|
| 57 |
return rows |
| 58 |
}) |
| 59 |
|
| 60 |
// Row drag handlers |
| 61 |
const onRowDragStart = (event: DragEvent, row: FieldRow) => { |
| 62 |
if (!event.dataTransfer) return |
| 63 |
isDragging.value = true |
| 64 |
isDraggingRow.value = true |
| 65 |
draggedRow.value = row |
| 66 |
|
| 67 |
event.dataTransfer.effectAllowed = 'move' |
| 68 |
event.dataTransfer.setData('rowData', JSON.stringify({ rowIndex: row.rowIndex })) |
| 69 |
|
| 70 |
const rowWrapper = (event.target as HTMLElement).closest( |
| 71 |
'.ivyforms-form-builder__row-wrapper', |
| 72 |
) as HTMLElement |
| 73 |
if (rowWrapper) { |
| 74 |
const clone = rowWrapper.cloneNode(true) as HTMLElement |
| 75 |
clone.style.position = 'absolute' |
| 76 |
clone.style.top = '-9999px' |
| 77 |
clone.style.opacity = '0.9' |
| 78 |
clone.style.pointerEvents = 'none' |
| 79 |
clone.style.width = `${rowWrapper.clientWidth}px` |
| 80 |
clone.style.background = 'var(--map-ground-level-1-foreground)' |
| 81 |
clone.style.padding = '8px' |
| 82 |
clone.style.borderRadius = '8px' |
| 83 |
clone.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)' |
| 84 |
clone.style.marginTop = '0' |
| 85 |
|
| 86 |
// Hide row action buttons in the clone |
| 87 |
const rowActions = clone.querySelectorAll( |
| 88 |
'.ivyforms-draggable__row-actions, .ivyforms-form-builder__row-actions', |
| 89 |
) |
| 90 |
rowActions.forEach((btn) => ((btn as HTMLElement).style.display = 'none')) |
| 91 |
|
| 92 |
// Hide field action buttons in the clone |
| 93 |
const actionButtons = clone.querySelectorAll('.ivyforms-field-unit__action-buttons') |
| 94 |
actionButtons.forEach((btn) => ((btn as HTMLElement).style.display = 'none')) |
| 95 |
|
| 96 |
document.body.appendChild(clone) |
| 97 |
event.dataTransfer.setDragImage(clone, rowWrapper.clientWidth / 2, 10) |
| 98 |
setTimeout(() => document.body.removeChild(clone), 0) |
| 99 |
} |
| 100 |
} |
| 101 |
|
| 102 |
const onRowDragEnd = () => { |
| 103 |
isDragging.value = false |
| 104 |
isDraggingRow.value = false |
| 105 |
draggedRow.value = null |
| 106 |
dropZoneRowIndex.value = null |
| 107 |
} |
| 108 |
|
| 109 |
// Field drag handlers |
| 110 |
const onFieldDragStart = (event: DragEvent, field: Field, row: FieldRow) => { |
| 111 |
if (!event.dataTransfer) return |
| 112 |
isDragging.value = true |
| 113 |
draggedField.value = field |
| 114 |
draggedFromRow.value = row |
| 115 |
|
| 116 |
event.dataTransfer.effectAllowed = 'move' |
| 117 |
event.dataTransfer.setData('fieldData', JSON.stringify(field)) |
| 118 |
|
| 119 |
const fieldElement = (event.target as HTMLElement).closest( |
| 120 |
'.ivyforms-form-builder__field-container', |
| 121 |
) as HTMLElement |
| 122 |
if (fieldElement) { |
| 123 |
const clone = fieldElement.cloneNode(true) as HTMLElement |
| 124 |
clone.style.position = 'absolute' |
| 125 |
clone.style.top = '-9999px' |
| 126 |
clone.style.opacity = '0.9' |
| 127 |
clone.style.pointerEvents = 'none' |
| 128 |
clone.style.width = `${fieldElement.clientWidth}px` |
| 129 |
clone.style.background = 'var(--map-ground-level-1-foreground)' |
| 130 |
clone.style.padding = '8px' |
| 131 |
clone.style.borderRadius = '8px' |
| 132 |
clone.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)' |
| 133 |
clone.style.margin = '0' |
| 134 |
|
| 135 |
// Remove margin from the ivyforms-field-unit inside the clone |
| 136 |
const fieldUnit = clone.querySelector('.ivyforms-field-unit') |
| 137 |
if (fieldUnit) { |
| 138 |
;(fieldUnit as HTMLElement).style.margin = '0' |
| 139 |
} |
| 140 |
|
| 141 |
// Hide action buttons in the clone |
| 142 |
const actionButtons = clone.querySelectorAll('.ivyforms-field-unit__action-buttons') |
| 143 |
actionButtons.forEach((btn) => ((btn as HTMLElement).style.display = 'none')) |
| 144 |
|
| 145 |
document.body.appendChild(clone) |
| 146 |
event.dataTransfer.setDragImage(clone, fieldElement.clientWidth / 2, 10) |
| 147 |
setTimeout(() => document.body.removeChild(clone), 0) |
| 148 |
} |
| 149 |
} |
| 150 |
|
| 151 |
const onFieldDragEnd = () => { |
| 152 |
isDragging.value = false |
| 153 |
draggedField.value = null |
| 154 |
draggedFromRow.value = null |
| 155 |
dropIndicator.value = null |
| 156 |
} |
| 157 |
|
| 158 |
// Drop zone handlers (for rows) |
| 159 |
let rowZoneLeaveTimeout: ReturnType<typeof setTimeout> | null = null |
| 160 |
|
| 161 |
const onDragOverRowZone = (event: DragEvent, afterRowIndex: number) => { |
| 162 |
if (!event.dataTransfer) return |
| 163 |
event.preventDefault() |
| 164 |
event.dataTransfer.dropEffect = 'move' |
| 165 |
|
| 166 |
// Set isDragging if not already set (happens when dragging from panel) |
| 167 |
if (!isDragging.value) { |
| 168 |
isDragging.value = true |
| 169 |
} |
| 170 |
|
| 171 |
if (rowZoneLeaveTimeout) { |
| 172 |
clearTimeout(rowZoneLeaveTimeout) |
| 173 |
rowZoneLeaveTimeout = null |
| 174 |
} |
| 175 |
|
| 176 |
// Activate drop zone - the visibility is controlled by onDragOverRow logic |
| 177 |
dropZoneRowIndex.value = afterRowIndex |
| 178 |
} |
| 179 |
|
| 180 |
const onDragLeaveRowZone = () => { |
| 181 |
if (rowZoneLeaveTimeout) { |
| 182 |
clearTimeout(rowZoneLeaveTimeout) |
| 183 |
} |
| 184 |
rowZoneLeaveTimeout = setTimeout(() => { |
| 185 |
dropZoneRowIndex.value = null |
| 186 |
}, 50) |
| 187 |
} |
| 188 |
|
| 189 |
const onDropRowZone = (event: DragEvent, afterRowIndex: number) => { |
| 190 |
event.stopPropagation() |
| 191 |
event.preventDefault() |
| 192 |
|
| 193 |
// IMMEDIATE reset to prevent UI blocking - do this FIRST before any other logic |
| 194 |
isDragging.value = false |
| 195 |
isDraggingRow.value = false |
| 196 |
|
| 197 |
if (!event.dataTransfer) { |
| 198 |
dropZoneRowIndex.value = null |
| 199 |
draggedField.value = null |
| 200 |
draggedRow.value = null |
| 201 |
return |
| 202 |
} |
| 203 |
|
| 204 |
// Clear draggedField immediately to remove opacity from ghost |
| 205 |
draggedField.value = null |
| 206 |
|
| 207 |
// Check if dropping a whole row |
| 208 |
const rowData = event.dataTransfer.getData('rowData') |
| 209 |
if (rowData) { |
| 210 |
try { |
| 211 |
const { rowIndex: draggedRowIndex } = JSON.parse(rowData) |
| 212 |
const newRowIndex = afterRowIndex + 1 |
| 213 |
|
| 214 |
if (draggedRowIndex === newRowIndex || draggedRowIndex === newRowIndex - 1) { |
| 215 |
dropZoneRowIndex.value = null |
| 216 |
return |
| 217 |
} |
| 218 |
|
| 219 |
formBuilderStore.fields.forEach((f) => { |
| 220 |
const currentRow = f.rowIndex ?? 0 |
| 221 |
|
| 222 |
if (draggedRowIndex < newRowIndex) { |
| 223 |
// Moving row down |
| 224 |
if (currentRow > draggedRowIndex && currentRow < newRowIndex) { |
| 225 |
// Shift rows between dragged and target up by 1 |
| 226 |
f.rowIndex = currentRow - 1 |
| 227 |
} else if (currentRow === draggedRowIndex) { |
| 228 |
// Move dragged row to new position |
| 229 |
f.rowIndex = newRowIndex - 1 |
| 230 |
} |
| 231 |
} else { |
| 232 |
// Moving row up |
| 233 |
if (currentRow >= newRowIndex && currentRow < draggedRowIndex) { |
| 234 |
// Shift rows between target and dragged down by 1 |
| 235 |
f.rowIndex = currentRow + 1 |
| 236 |
} else if (currentRow === draggedRowIndex) { |
| 237 |
// Move dragged row to new position |
| 238 |
f.rowIndex = newRowIndex |
| 239 |
} |
| 240 |
} |
| 241 |
}) |
| 242 |
|
| 243 |
formBuilderStore.updateFieldPositions() |
| 244 |
|
| 245 |
// Fix columnIndex for all rows after reordering |
| 246 |
// Sort by columnIndex to maintain field order within each row |
| 247 |
const uniqueRowIndexes = [...new Set(formBuilderStore.fields.map((f) => f.rowIndex ?? 0))] |
| 248 |
uniqueRowIndexes.forEach((rowIdx) => { |
| 249 |
const rowFields = formBuilderStore.fields.filter((f) => f.rowIndex === rowIdx) |
| 250 |
// Sort by columnIndex to maintain the correct left-to-right order |
| 251 |
rowFields.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0)) |
| 252 |
// Reassign columnIndex sequentially |
| 253 |
rowFields.forEach((f, idx) => { |
| 254 |
f.columnIndex = idx |
| 255 |
}) |
| 256 |
}) |
| 257 |
|
| 258 |
// Recalculate widths for all affected rows after row reordering |
| 259 |
const allRowIndexes = [...new Set(formBuilderStore.fields.map((f) => f.rowIndex ?? 0))] |
| 260 |
allRowIndexes.forEach((rowIdx) => { |
| 261 |
formBuilderStore.recalculateRowWidths(rowIdx) |
| 262 |
}) |
| 263 |
|
| 264 |
dropZoneRowIndex.value = null |
| 265 |
isDragging.value = false |
| 266 |
isDraggingRow.value = false |
| 267 |
draggedRow.value = null |
| 268 |
return |
| 269 |
} catch { |
| 270 |
// Error parsing row data |
| 271 |
dropZoneRowIndex.value = null |
| 272 |
isDragging.value = false |
| 273 |
isDraggingRow.value = false |
| 274 |
} |
| 275 |
} |
| 276 |
|
| 277 |
// Otherwise, dropping a field to create new row |
| 278 |
let fieldData = event.dataTransfer.getData('fieldData') |
| 279 |
if (!fieldData) { |
| 280 |
fieldData = event.dataTransfer.getData('application/json') |
| 281 |
} |
| 282 |
|
| 283 |
if (!fieldData) { |
| 284 |
dropZoneRowIndex.value = null |
| 285 |
dropIndicator.value = null |
| 286 |
return |
| 287 |
} |
| 288 |
|
| 289 |
try { |
| 290 |
const parsedField = JSON.parse(fieldData) as Field |
| 291 |
const field = |
| 292 |
formBuilderStore.fields.find((f) => f.fieldIndex === parsedField.fieldIndex) || parsedField |
| 293 |
|
| 294 |
const existingFieldIndex = formBuilderStore.fields.findIndex( |
| 295 |
(f) => f.fieldIndex === field.fieldIndex, |
| 296 |
) |
| 297 |
const isExistingField = existingFieldIndex !== -1 |
| 298 |
const newRowIndex = afterRowIndex + 1 |
| 299 |
|
| 300 |
if (isExistingField) { |
| 301 |
const oldRowIndex = field.rowIndex ?? 0 |
| 302 |
|
| 303 |
// Shift rows to make space |
| 304 |
formBuilderStore.fields.forEach((f) => { |
| 305 |
const fRow = f.rowIndex ?? 0 |
| 306 |
if (fRow >= newRowIndex && f.fieldIndex !== field.fieldIndex) { |
| 307 |
f.rowIndex = fRow + 1 |
| 308 |
} |
| 309 |
}) |
| 310 |
|
| 311 |
field.rowIndex = newRowIndex |
| 312 |
field.columnIndex = 0 |
| 313 |
field.width = 100 |
| 314 |
|
| 315 |
// Clean up old row |
| 316 |
const oldRowFields = formBuilderStore.fields.filter( |
| 317 |
(f) => f.rowIndex === oldRowIndex && f.fieldIndex !== field.fieldIndex, |
| 318 |
) |
| 319 |
oldRowFields.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0)) |
| 320 |
oldRowFields.forEach((f, idx) => { |
| 321 |
f.columnIndex = idx |
| 322 |
}) |
| 323 |
formBuilderStore.recalculateRowWidths(oldRowIndex) |
| 324 |
|
| 325 |
// Compact row indexes |
| 326 |
const allRowIndexes = [ |
| 327 |
...new Set(formBuilderStore.fields.map((f) => f.rowIndex ?? 0)), |
| 328 |
].sort((a, b) => a - b) |
| 329 |
const rowMapping = new Map<number, number>() |
| 330 |
allRowIndexes.forEach((oldIdx, newIdx) => { |
| 331 |
rowMapping.set(oldIdx, newIdx) |
| 332 |
}) |
| 333 |
formBuilderStore.fields.forEach((f) => { |
| 334 |
const oldIdx = f.rowIndex ?? 0 |
| 335 |
f.rowIndex = rowMapping.get(oldIdx) ?? oldIdx |
| 336 |
}) |
| 337 |
} else { |
| 338 |
// New field from panel |
| 339 |
formBuilderStore.fields.forEach((f) => { |
| 340 |
const fRow = f.rowIndex ?? 0 |
| 341 |
if (fRow >= newRowIndex) { |
| 342 |
f.rowIndex = fRow + 1 |
| 343 |
} |
| 344 |
}) |
| 345 |
|
| 346 |
field.rowIndex = newRowIndex |
| 347 |
field.columnIndex = 0 |
| 348 |
field.width = 100 |
| 349 |
formBuilderStore.addField(field) |
| 350 |
} |
| 351 |
|
| 352 |
formBuilderStore.updateFieldPositions() |
| 353 |
|
| 354 |
// Recalculate widths only for affected rows (new row and old row if moving existing field) |
| 355 |
if (isExistingField && draggedFromRow.value !== null) { |
| 356 |
// Recalculate old row where field was moved from |
| 357 |
formBuilderStore.recalculateRowWidths(draggedFromRow.value.rowIndex) |
| 358 |
} |
| 359 |
// Recalculate new row where field was dropped |
| 360 |
formBuilderStore.recalculateRowWidths(newRowIndex) |
| 361 |
|
| 362 |
dropZoneRowIndex.value = null |
| 363 |
dropIndicator.value = null |
| 364 |
isDragging.value = false |
| 365 |
draggedField.value = null |
| 366 |
draggedFromRow.value = null |
| 367 |
} catch { |
| 368 |
dropZoneRowIndex.value = null |
| 369 |
dropIndicator.value = null |
| 370 |
isDragging.value = false |
| 371 |
} |
| 372 |
} |
| 373 |
|
| 374 |
// Field drop handlers |
| 375 |
const onDragOverRow = (event: DragEvent) => { |
| 376 |
// CRITICAL: If not dragging, do NOTHING - don't even check dataTransfer |
| 377 |
if (!isDragging.value && !formBuilderStore.isDraggingFromPanel) { |
| 378 |
return |
| 379 |
} |
| 380 |
|
| 381 |
if (!event.dataTransfer) return |
| 382 |
|
| 383 |
// Don't prevent default if we're in a gap (let container handle it) |
| 384 |
const target = event.target as HTMLElement |
| 385 |
if (!target.closest('.ivyforms-form-builder__row-wrapper')) { |
| 386 |
return |
| 387 |
} |
| 388 |
|
| 389 |
event.preventDefault() |
| 390 |
event.dataTransfer.dropEffect = 'move' |
| 391 |
|
| 392 |
// Set isDragging if not already set (happens when dragging from panel) |
| 393 |
if (!isDragging.value) { |
| 394 |
isDragging.value = true |
| 395 |
} |
| 396 |
|
| 397 |
// Get the row wrapper element (current target is row wrapper now) |
| 398 |
const rowWrapper = event.currentTarget as HTMLElement |
| 399 |
|
| 400 |
if (!rowWrapper) return |
| 401 |
|
| 402 |
// Get the row index from data attribute |
| 403 |
const currentRowIndex = parseInt(rowWrapper.getAttribute('data-row-index') || '0', 10) |
| 404 |
|
| 405 |
const rect = rowWrapper.getBoundingClientRect() |
| 406 |
const mouseY = event.clientY |
| 407 |
|
| 408 |
// When dragging a ROW: use 50% height split (similar to left/right field logic) |
| 409 |
if (isDraggingRow.value) { |
| 410 |
const midpoint = rect.top + rect.height / 2 |
| 411 |
const isTopHalf = mouseY < midpoint |
| 412 |
|
| 413 |
// Top half - show drop zone above this row |
| 414 |
if (isTopHalf) { |
| 415 |
dropZoneRowIndex.value = currentRowIndex - 1 |
| 416 |
} |
| 417 |
// Bottom half - show drop zone below this row |
| 418 |
else { |
| 419 |
dropZoneRowIndex.value = currentRowIndex |
| 420 |
} |
| 421 |
return |
| 422 |
} |
| 423 |
|
| 424 |
// When dragging a FIELD: detect padding areas (outside field boundaries) |
| 425 |
const rowFieldsElement = rowWrapper.querySelector( |
| 426 |
'.ivyforms-form-builder__row-fields', |
| 427 |
) as HTMLElement |
| 428 |
if (!rowFieldsElement) { |
| 429 |
dropZoneRowIndex.value = null |
| 430 |
return |
| 431 |
} |
| 432 |
|
| 433 |
const fieldsRect = rowFieldsElement.getBoundingClientRect() |
| 434 |
|
| 435 |
// Calculate padding areas (row wrapper has p-1 = 8px padding) |
| 436 |
const paddingTop = fieldsRect.top - rect.top |
| 437 |
const paddingBottom = rect.bottom - fieldsRect.bottom |
| 438 |
|
| 439 |
// Define threshold for showing drop zones (use full padding area) |
| 440 |
const topThreshold = rect.top + paddingTop |
| 441 |
const bottomThreshold = rect.bottom - paddingBottom |
| 442 |
|
| 443 |
// Show top drop zone if in top padding area |
| 444 |
if (mouseY < topThreshold) { |
| 445 |
dropZoneRowIndex.value = currentRowIndex - 1 |
| 446 |
} |
| 447 |
// Show bottom drop zone if in bottom padding area |
| 448 |
else if (mouseY > bottomThreshold) { |
| 449 |
dropZoneRowIndex.value = currentRowIndex |
| 450 |
} |
| 451 |
// Clear drop zone when in the middle (over fields area) |
| 452 |
else { |
| 453 |
dropZoneRowIndex.value = null |
| 454 |
} |
| 455 |
} |
| 456 |
|
| 457 |
const onDragOverField = (event: DragEvent, targetField: Field, rowFields: Field[]) => { |
| 458 |
// CRITICAL: If not dragging, do NOTHING |
| 459 |
if (!isDragging.value && !formBuilderStore.isDraggingFromPanel) { |
| 460 |
return |
| 461 |
} |
| 462 |
|
| 463 |
if (isDraggingRow.value) { |
| 464 |
dropIndicator.value = null |
| 465 |
return |
| 466 |
} |
| 467 |
if (!event.dataTransfer) return |
| 468 |
event.preventDefault() |
| 469 |
event.stopPropagation() // Stop propagation to prevent row handler from being called |
| 470 |
|
| 471 |
// Set isDragging if not already set (happens when dragging from panel) |
| 472 |
if (!isDragging.value) { |
| 473 |
isDragging.value = true |
| 474 |
} |
| 475 |
|
| 476 |
// Clear row drop zone indicator when dragging over fields |
| 477 |
dropZoneRowIndex.value = null |
| 478 |
|
| 479 |
// Check if dragging from panel (new field) or from a different row |
| 480 |
const isDraggingFromPanel = !draggedField.value |
| 481 |
const isDraggingFromDifferentRow = |
| 482 |
draggedField.value && draggedFromRow.value?.rowIndex !== targetField.rowIndex |
| 483 |
|
| 484 |
// Don't show indicators if row already has 5 fields and it's a new field or from different row |
| 485 |
if ((isDraggingFromPanel || isDraggingFromDifferentRow) && rowFields.length >= 5) { |
| 486 |
dropIndicator.value = null |
| 487 |
return |
| 488 |
} |
| 489 |
|
| 490 |
const element = event.currentTarget as HTMLElement |
| 491 |
const rect = element.getBoundingClientRect() |
| 492 |
const mouseX = event.clientX |
| 493 |
const midpoint = rect.left + rect.width / 2 |
| 494 |
const isLeftHalf = mouseX < midpoint |
| 495 |
|
| 496 |
// Find the current field's position in the row |
| 497 |
const currentFieldIndex = rowFields.findIndex((f) => f.fieldIndex === targetField.fieldIndex) |
| 498 |
const previousField = currentFieldIndex > 0 ? rowFields[currentFieldIndex - 1] : null |
| 499 |
|
| 500 |
// If hovering on left half and there's a previous field, show right indicator of previous field |
| 501 |
if (isLeftHalf && previousField) { |
| 502 |
dropIndicator.value = { |
| 503 |
fieldIndex: previousField.fieldIndex, |
| 504 |
position: 'right', |
| 505 |
} |
| 506 |
} else if (isLeftHalf && currentFieldIndex === 0) { |
| 507 |
// First field, left half - show left indicator (for dropping before first field) |
| 508 |
dropIndicator.value = { |
| 509 |
fieldIndex: targetField.fieldIndex, |
| 510 |
position: 'left', |
| 511 |
} |
| 512 |
} else { |
| 513 |
// Right half - show right indicator of current field |
| 514 |
dropIndicator.value = { |
| 515 |
fieldIndex: targetField.fieldIndex, |
| 516 |
position: 'right', |
| 517 |
} |
| 518 |
} |
| 519 |
} |
| 520 |
|
| 521 |
const onDragLeaveField = () => { |
| 522 |
dropIndicator.value = null |
| 523 |
} |
| 524 |
|
| 525 |
const onDropInRow = (event: DragEvent) => { |
| 526 |
event.preventDefault() |
| 527 |
event.stopPropagation() |
| 528 |
|
| 529 |
// If dropZoneRowIndex is set, handle as row zone drop |
| 530 |
if (dropZoneRowIndex.value !== null) { |
| 531 |
onDropRowZone(event, dropZoneRowIndex.value) |
| 532 |
return |
| 533 |
} |
| 534 |
|
| 535 |
// Otherwise, ignore if dragging a row |
| 536 |
if (isDraggingRow.value) return |
| 537 |
} |
| 538 |
|
| 539 |
// Rows container handlers (for first drop zone and gaps between rows) |
| 540 |
const onDragOverRowsContainer = (event: DragEvent) => { |
| 541 |
// CRITICAL: If not dragging, do NOTHING - don't even check dataTransfer |
| 542 |
// This prevents ANY interference with normal page interaction |
| 543 |
if (!isDragging.value && !formBuilderStore.isDraggingFromPanel) { |
| 544 |
return |
| 545 |
} |
| 546 |
|
| 547 |
if (!event.dataTransfer) return |
| 548 |
|
| 549 |
const target = event.target as HTMLElement |
| 550 |
|
| 551 |
// Check if hovering directly on a drop zone element |
| 552 |
if ( |
| 553 |
target.classList.contains('ivyforms-draggable__row-drop-zone') || |
| 554 |
target.classList.contains('ivyforms-form-builder__row-drop-zone') |
| 555 |
) { |
| 556 |
event.preventDefault() |
| 557 |
event.dataTransfer.dropEffect = 'move' |
| 558 |
|
| 559 |
// Determine which drop zone based on classes |
| 560 |
if (target.classList.contains('is-active')) { |
| 561 |
// Drop zone is already active, keep current dropZoneRowIndex |
| 562 |
return |
| 563 |
} |
| 564 |
} |
| 565 |
|
| 566 |
// Check if hovering in the gap between rows (not over a row wrapper) |
| 567 |
if (!target.closest('.ivyforms-form-builder__row-wrapper')) { |
| 568 |
event.preventDefault() |
| 569 |
event.dataTransfer.dropEffect = 'move' |
| 570 |
|
| 571 |
const container = event.currentTarget as HTMLElement |
| 572 |
const mouseY = event.clientY |
| 573 |
|
| 574 |
// Get all row wrappers to find which gap we're in |
| 575 |
const rowWrappers = Array.from( |
| 576 |
container.querySelectorAll('.ivyforms-form-builder__row-wrapper'), |
| 577 |
) |
| 578 |
|
| 579 |
if (rowWrappers.length === 0) return |
| 580 |
|
| 581 |
// Check if above first row |
| 582 |
const firstRowRect = rowWrappers[0].getBoundingClientRect() |
| 583 |
if (mouseY < firstRowRect.top) { |
| 584 |
dropZoneRowIndex.value = -1 |
| 585 |
return |
| 586 |
} |
| 587 |
|
| 588 |
// Check if in gap between rows |
| 589 |
for (let i = 0; i < rowWrappers.length - 1; i++) { |
| 590 |
const currentRowRect = rowWrappers[i].getBoundingClientRect() |
| 591 |
const nextRowRect = rowWrappers[i + 1].getBoundingClientRect() |
| 592 |
|
| 593 |
if (mouseY > currentRowRect.bottom && mouseY < nextRowRect.top) { |
| 594 |
const currentRowIndex = parseInt(rowWrappers[i].getAttribute('data-row-index') || '0', 10) |
| 595 |
dropZoneRowIndex.value = currentRowIndex |
| 596 |
return |
| 597 |
} |
| 598 |
} |
| 599 |
|
| 600 |
// Check if below last row |
| 601 |
const lastRowRect = rowWrappers[rowWrappers.length - 1].getBoundingClientRect() |
| 602 |
if (mouseY > lastRowRect.bottom) { |
| 603 |
const lastRowIndex = parseInt( |
| 604 |
rowWrappers[rowWrappers.length - 1].getAttribute('data-row-index') || '0', |
| 605 |
10, |
| 606 |
) |
| 607 |
dropZoneRowIndex.value = lastRowIndex |
| 608 |
} |
| 609 |
} |
| 610 |
} |
| 611 |
|
| 612 |
const onDropInRowsContainer = (event: DragEvent) => { |
| 613 |
// Handle drops on any drop zone (gaps between rows) |
| 614 |
const target = event.target as HTMLElement |
| 615 |
|
| 616 |
// Only handle if not dropping on a row wrapper (let row handle its own drops) |
| 617 |
if (!target.closest('.ivyforms-form-builder__row-wrapper')) { |
| 618 |
if (dropZoneRowIndex.value !== null) { |
| 619 |
event.preventDefault() |
| 620 |
event.stopPropagation() |
| 621 |
onDropRowZone(event, dropZoneRowIndex.value) |
| 622 |
} |
| 623 |
} |
| 624 |
} |
| 625 |
|
| 626 |
const onDropOnField = (event: DragEvent, targetField: Field, row: FieldRow) => { |
| 627 |
// IMMEDIATE reset to prevent UI blocking - do this FIRST |
| 628 |
isDragging.value = false |
| 629 |
isDraggingRow.value = false |
| 630 |
draggedField.value = null |
| 631 |
|
| 632 |
if (isDraggingRow.value) return |
| 633 |
if (!event.dataTransfer) return |
| 634 |
|
| 635 |
const fieldDataStr = event.dataTransfer.getData('fieldData') |
| 636 |
if (!fieldDataStr) return |
| 637 |
|
| 638 |
try { |
| 639 |
const parsedField = JSON.parse(fieldDataStr) as Field |
| 640 |
const field = |
| 641 |
formBuilderStore.fields.find((f) => f.fieldIndex === parsedField.fieldIndex) || parsedField |
| 642 |
|
| 643 |
const existingIndex = formBuilderStore.fields.findIndex( |
| 644 |
(f) => f.fieldIndex === field.fieldIndex, |
| 645 |
) |
| 646 |
const isMove = existingIndex !== -1 |
| 647 |
|
| 648 |
const fieldIsFromDifferentRow = isMove && field.rowIndex !== row.rowIndex |
| 649 |
const isNewField = !isMove |
| 650 |
if ((isNewField || fieldIsFromDifferentRow) && row.fields.length >= 5) { |
| 651 |
dropIndicator.value = null |
| 652 |
return |
| 653 |
} |
| 654 |
|
| 655 |
const targetFieldInStore = formBuilderStore.fields.find( |
| 656 |
(f) => f.fieldIndex === targetField.fieldIndex, |
| 657 |
) |
| 658 |
let targetColumnIndex = targetFieldInStore?.columnIndex ?? 0 |
| 659 |
|
| 660 |
if (dropIndicator.value && dropIndicator.value.fieldIndex === targetField.fieldIndex) { |
| 661 |
targetColumnIndex = |
| 662 |
dropIndicator.value.position === 'left' ? targetColumnIndex : targetColumnIndex + 1 |
| 663 |
} |
| 664 |
|
| 665 |
if (isMove) { |
| 666 |
const oldRowIndex = field.rowIndex ?? 0 |
| 667 |
const oldColumnIndex = field.columnIndex ?? 0 |
| 668 |
const wasInSameRow = oldRowIndex === row.rowIndex |
| 669 |
|
| 670 |
if (wasInSameRow) { |
| 671 |
if (field.fieldIndex === targetField.fieldIndex) { |
| 672 |
dropIndicator.value = null |
| 673 |
return |
| 674 |
} |
| 675 |
|
| 676 |
const fieldsInRow = formBuilderStore.fields.filter((f) => f.rowIndex === row.rowIndex) |
| 677 |
|
| 678 |
fieldsInRow.forEach((f) => { |
| 679 |
if (f.fieldIndex === field.fieldIndex) return |
| 680 |
const currentCol = f.columnIndex ?? 0 |
| 681 |
|
| 682 |
if (targetColumnIndex <= oldColumnIndex) { |
| 683 |
if (currentCol >= targetColumnIndex && currentCol < oldColumnIndex) { |
| 684 |
f.columnIndex = currentCol + 1 |
| 685 |
} |
| 686 |
} else { |
| 687 |
if (currentCol > oldColumnIndex && currentCol < targetColumnIndex) { |
| 688 |
f.columnIndex = currentCol - 1 |
| 689 |
} |
| 690 |
} |
| 691 |
}) |
| 692 |
|
| 693 |
field.columnIndex = targetColumnIndex |
| 694 |
fieldsInRow.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0)) |
| 695 |
fieldsInRow.forEach((f, idx) => (f.columnIndex = idx)) |
| 696 |
|
| 697 |
// Don't recalculate widths when reordering within same row - preserve existing widths |
| 698 |
formBuilderStore.updateFieldPositions() |
| 699 |
} else { |
| 700 |
// Moving to a different row - reset all widths to even distribution |
| 701 |
const targetRowFieldsBeforeMove = formBuilderStore.fields.filter( |
| 702 |
(f) => f.rowIndex === row.rowIndex && f.fieldIndex !== field.fieldIndex, |
| 703 |
) |
| 704 |
|
| 705 |
const totalFieldsAfter = targetRowFieldsBeforeMove.length + 1 |
| 706 |
const evenWidth = |
| 707 |
totalFieldsAfter >= 5 ? 20 : Math.round((100 / totalFieldsAfter) * 10) / 10 |
| 708 |
|
| 709 |
// Reset all fields in target row to even width |
| 710 |
targetRowFieldsBeforeMove.forEach((f) => { |
| 711 |
f.width = evenWidth |
| 712 |
}) |
| 713 |
|
| 714 |
field.width = evenWidth |
| 715 |
|
| 716 |
field.rowIndex = row.rowIndex |
| 717 |
field.columnIndex = targetColumnIndex |
| 718 |
|
| 719 |
const oldRowFields = formBuilderStore.fields.filter( |
| 720 |
(f) => f.rowIndex === oldRowIndex && f.fieldIndex !== field.fieldIndex, |
| 721 |
) |
| 722 |
oldRowFields.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0)) |
| 723 |
oldRowFields.forEach((f, idx) => { |
| 724 |
f.columnIndex = idx |
| 725 |
}) |
| 726 |
|
| 727 |
formBuilderStore.fields.forEach((f) => { |
| 728 |
if (f.rowIndex === row.rowIndex && f.fieldIndex !== field.fieldIndex) { |
| 729 |
if ((f.columnIndex ?? 0) >= targetColumnIndex) { |
| 730 |
f.columnIndex = (f.columnIndex ?? 0) + 1 |
| 731 |
} |
| 732 |
} |
| 733 |
}) |
| 734 |
|
| 735 |
const newRowFields = formBuilderStore.fields.filter((f) => f.rowIndex === row.rowIndex) |
| 736 |
newRowFields.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0)) |
| 737 |
newRowFields.forEach((f, idx) => (f.columnIndex = idx)) |
| 738 |
|
| 739 |
formBuilderStore.recalculateRowWidths(oldRowIndex, false) |
| 740 |
|
| 741 |
const allRowIndexes = [ |
| 742 |
...new Set(formBuilderStore.fields.map((f) => f.rowIndex ?? 0)), |
| 743 |
].sort((a, b) => a - b) |
| 744 |
const rowMapping = new Map<number, number>() |
| 745 |
allRowIndexes.forEach((oldIdx, newIdx) => { |
| 746 |
rowMapping.set(oldIdx, newIdx) |
| 747 |
}) |
| 748 |
formBuilderStore.fields.forEach((f) => { |
| 749 |
const oldIdx = f.rowIndex ?? 0 |
| 750 |
f.rowIndex = rowMapping.get(oldIdx) ?? oldIdx |
| 751 |
}) |
| 752 |
|
| 753 |
formBuilderStore.updateFieldPositions() |
| 754 |
} |
| 755 |
} else { |
| 756 |
// Dropping a new field - reset all widths to even distribution |
| 757 |
const fieldsInRow = formBuilderStore.fields.filter((f) => f.rowIndex === row.rowIndex) |
| 758 |
const totalFieldsAfter = fieldsInRow.length + 1 |
| 759 |
const evenWidth = |
| 760 |
totalFieldsAfter >= 5 ? 20 : Math.round((100 / totalFieldsAfter) * 10) / 10 |
| 761 |
|
| 762 |
// Reset all fields in row to even width |
| 763 |
fieldsInRow.forEach((f) => { |
| 764 |
f.width = evenWidth |
| 765 |
}) |
| 766 |
|
| 767 |
field.width = evenWidth |
| 768 |
|
| 769 |
fieldsInRow.forEach((f) => { |
| 770 |
if ((f.columnIndex ?? 0) >= targetColumnIndex) { |
| 771 |
f.columnIndex = (f.columnIndex ?? 0) + 1 |
| 772 |
} |
| 773 |
}) |
| 774 |
|
| 775 |
field.rowIndex = row.rowIndex |
| 776 |
field.columnIndex = targetColumnIndex |
| 777 |
|
| 778 |
formBuilderStore.addField(field) |
| 779 |
|
| 780 |
const allFieldsInRow = formBuilderStore.fields.filter((f) => f.rowIndex === row.rowIndex) |
| 781 |
allFieldsInRow.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0)) |
| 782 |
allFieldsInRow.forEach((f, idx) => { |
| 783 |
f.columnIndex = idx |
| 784 |
}) |
| 785 |
} |
| 786 |
|
| 787 |
formBuilderStore.updateFieldPositions() |
| 788 |
dropIndicator.value = null |
| 789 |
isDragging.value = false |
| 790 |
draggedField.value = null |
| 791 |
draggedFromRow.value = null |
| 792 |
} catch { |
| 793 |
dropIndicator.value = null |
| 794 |
isDragging.value = false |
| 795 |
draggedField.value = null |
| 796 |
draggedFromRow.value = null |
| 797 |
} |
| 798 |
} |
| 799 |
|
| 800 |
// Empty state drag handlers |
| 801 |
const emptyDropPosition = ref<'left' | 'right' | 'top' | 'bottom' | null>(null) |
| 802 |
|
| 803 |
const onDragOverEmpty = (event: DragEvent) => { |
| 804 |
// CRITICAL: If not dragging, do NOTHING |
| 805 |
if (!isDragging.value && !formBuilderStore.isDraggingFromPanel) { |
| 806 |
return |
| 807 |
} |
| 808 |
|
| 809 |
event.preventDefault() |
| 810 |
const emptyArea = event.currentTarget as HTMLElement |
| 811 |
const rect = emptyArea.getBoundingClientRect() |
| 812 |
const offsetX = event.clientX - rect.left |
| 813 |
const offsetY = event.clientY - rect.top |
| 814 |
|
| 815 |
// Determine which quadrant of the empty area |
| 816 |
const isTop = offsetY < rect.height / 2 |
| 817 |
const isLeft = offsetX < rect.width / 2 |
| 818 |
|
| 819 |
if (Math.abs(offsetY - rect.height / 2) > Math.abs(offsetX - rect.width / 2)) { |
| 820 |
// Vertical |
| 821 |
emptyDropPosition.value = isTop ? 'top' : 'bottom' |
| 822 |
} else { |
| 823 |
// Horizontal |
| 824 |
emptyDropPosition.value = isLeft ? 'left' : 'right' |
| 825 |
} |
| 826 |
|
| 827 |
emptyArea.classList.add('dragging-over') |
| 828 |
emptyArea.setAttribute('data-drop-position', emptyDropPosition.value) |
| 829 |
} |
| 830 |
|
| 831 |
const onDragLeaveEmpty = (event: DragEvent) => { |
| 832 |
event.preventDefault() |
| 833 |
const emptyArea = event.currentTarget as HTMLElement |
| 834 |
emptyArea.classList.remove('dragging-over') |
| 835 |
emptyArea.removeAttribute('data-drop-position') |
| 836 |
emptyDropPosition.value = null |
| 837 |
} |
| 838 |
|
| 839 |
const onDropEmpty = (event: DragEvent) => { |
| 840 |
event.preventDefault() |
| 841 |
event.stopPropagation() |
| 842 |
|
| 843 |
// IMMEDIATE reset to prevent UI blocking - do this FIRST |
| 844 |
isDragging.value = false |
| 845 |
isDraggingRow.value = false |
| 846 |
draggedField.value = null |
| 847 |
|
| 848 |
const emptyArea = event.currentTarget as HTMLElement |
| 849 |
emptyArea.classList.remove('dragging-over') |
| 850 |
emptyArea.removeAttribute('data-drop-position') |
| 851 |
emptyDropPosition.value = null |
| 852 |
|
| 853 |
if (!event.dataTransfer) return |
| 854 |
|
| 855 |
let fieldData = event.dataTransfer.getData('fieldData') |
| 856 |
if (!fieldData) { |
| 857 |
fieldData = event.dataTransfer.getData('application/json') |
| 858 |
} |
| 859 |
|
| 860 |
if (!fieldData) return |
| 861 |
|
| 862 |
try { |
| 863 |
const field = JSON.parse(fieldData) as Field |
| 864 |
field.rowIndex = 0 |
| 865 |
field.columnIndex = 0 |
| 866 |
field.width = 100 |
| 867 |
formBuilderStore.addField(field) |
| 868 |
} catch { |
| 869 |
// Error parsing field data |
| 870 |
} |
| 871 |
} |
| 872 |
|
| 873 |
// Row management functions |
| 874 |
const isRowSelected = (row: FieldRow) => { |
| 875 |
return row.fields.some( |
| 876 |
(field) => field.fieldIndex === formBuilderStore.selectedField?.fieldIndex, |
| 877 |
) |
| 878 |
} |
| 879 |
|
| 880 |
const moveRowUp = (row: FieldRow) => { |
| 881 |
if (row.rowIndex === 0) return |
| 882 |
|
| 883 |
const selectedFieldIndex = selectedField.value?.fieldIndex |
| 884 |
const currentRowIndex = row.rowIndex |
| 885 |
const targetRowIndex = currentRowIndex - 1 |
| 886 |
|
| 887 |
// Swap rowIndex with fields in the row above |
| 888 |
fields.value.forEach((f) => { |
| 889 |
if (f.rowIndex === currentRowIndex) { |
| 890 |
f.rowIndex = targetRowIndex |
| 891 |
} else if (f.rowIndex === targetRowIndex) { |
| 892 |
f.rowIndex = currentRowIndex |
| 893 |
} |
| 894 |
}) |
| 895 |
|
| 896 |
formBuilderStore.updateFieldPositions() |
| 897 |
|
| 898 |
// Re-select using the store method (same as clicking on a field) |
| 899 |
if (selectedFieldIndex !== undefined) { |
| 900 |
formBuilderStore.selectField(selectedFieldIndex) |
| 901 |
|
| 902 |
// Scroll the field into view if it moved off-screen |
| 903 |
setTimeout(() => { |
| 904 |
const fieldElement = document.querySelector( |
| 905 |
`[data-field-index="${selectedFieldIndex}"]`, |
| 906 |
) as HTMLElement |
| 907 |
if (fieldElement) { |
| 908 |
fieldElement.style.scrollMargin = '20px' |
| 909 |
fieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) |
| 910 |
} |
| 911 |
}, 100) |
| 912 |
} |
| 913 |
} |
| 914 |
|
| 915 |
const moveRowDown = (row: FieldRow) => { |
| 916 |
const maxRow = Math.max(...fields.value.map((f) => f.rowIndex ?? 0)) |
| 917 |
if (row.rowIndex >= maxRow) return |
| 918 |
|
| 919 |
const selectedFieldIndex = selectedField.value?.fieldIndex |
| 920 |
const currentRowIndex = row.rowIndex |
| 921 |
const targetRowIndex = currentRowIndex + 1 |
| 922 |
|
| 923 |
// Swap rowIndex with fields in the row below |
| 924 |
fields.value.forEach((f) => { |
| 925 |
if (f.rowIndex === currentRowIndex) { |
| 926 |
f.rowIndex = targetRowIndex |
| 927 |
} else if (f.rowIndex === targetRowIndex) { |
| 928 |
f.rowIndex = currentRowIndex |
| 929 |
} |
| 930 |
}) |
| 931 |
|
| 932 |
formBuilderStore.updateFieldPositions() |
| 933 |
|
| 934 |
// Re-select using the store method (same as clicking on a field) |
| 935 |
if (selectedFieldIndex !== undefined) { |
| 936 |
formBuilderStore.selectField(selectedFieldIndex) |
| 937 |
|
| 938 |
// Scroll the field into view if it moved off-screen |
| 939 |
setTimeout(() => { |
| 940 |
const fieldElement = document.querySelector( |
| 941 |
`[data-field-index="${selectedFieldIndex}"]`, |
| 942 |
) as HTMLElement |
| 943 |
if (fieldElement) { |
| 944 |
fieldElement.style.scrollMargin = '20px' |
| 945 |
fieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) |
| 946 |
} |
| 947 |
}, 100) |
| 948 |
} |
| 949 |
} |
| 950 |
|
| 951 |
const duplicateRow = (row: FieldRow) => { |
| 952 |
const currentRowIndex = row.rowIndex |
| 953 |
const newRowIndex = currentRowIndex + 1 |
| 954 |
|
| 955 |
const selectedFieldIndex = formBuilderStore.selectedField?.fieldIndex |
| 956 |
const selectedFieldColumnIndex = row.fields.find( |
| 957 |
(f) => f.fieldIndex === selectedFieldIndex, |
| 958 |
)?.columnIndex |
| 959 |
|
| 960 |
formBuilderStore.fields.forEach((f) => { |
| 961 |
if ((f.rowIndex ?? 0) > currentRowIndex) { |
| 962 |
f.rowIndex = (f.rowIndex ?? 0) + 1 |
| 963 |
} |
| 964 |
}) |
| 965 |
|
| 966 |
const duplicatedFields: Field[] = [] |
| 967 |
row.fields.forEach((field) => { |
| 968 |
const newIndex = formBuilderStore.increaseCounter() |
| 969 |
const copy = duplicateFieldForStore(field, newIndex, getLabel) |
| 970 |
copy.rowIndex = newRowIndex |
| 971 |
copy.columnIndex = field.columnIndex |
| 972 |
copy.width = field.width |
| 973 |
duplicatedFields.push(copy) |
| 974 |
}) |
| 975 |
|
| 976 |
duplicatedFields.forEach((field) => { |
| 977 |
formBuilderStore.fields.push(field) |
| 978 |
}) |
| 979 |
|
| 980 |
formBuilderStore.updateFieldPositions() |
| 981 |
|
| 982 |
if (duplicatedFields.length > 0) { |
| 983 |
if (selectedFieldColumnIndex !== undefined) { |
| 984 |
const correspondingField = duplicatedFields.find( |
| 985 |
(f) => f.columnIndex === selectedFieldColumnIndex, |
| 986 |
) |
| 987 |
if (correspondingField) { |
| 988 |
formBuilderStore.selectedField = correspondingField |
| 989 |
} else { |
| 990 |
formBuilderStore.selectedField = duplicatedFields[0] |
| 991 |
} |
| 992 |
} else { |
| 993 |
formBuilderStore.selectedField = duplicatedFields[0] |
| 994 |
} |
| 995 |
formBuilderStore.activeTab = 'options' |
| 996 |
} |
| 997 |
} |
| 998 |
|
| 999 |
const deleteRow = (row: FieldRow) => { |
| 1000 |
const fieldIndexes = row.fields.map((f) => f.fieldIndex) |
| 1001 |
fieldIndexes.forEach((fieldIndex) => { |
| 1002 |
formBuilderStore.deleteField(fieldIndex) |
| 1003 |
}) |
| 1004 |
|
| 1005 |
const remainingRowIndexes = [ |
| 1006 |
...new Set(formBuilderStore.fields.map((f) => f.rowIndex ?? 0)), |
| 1007 |
].sort((a, b) => a - b) |
| 1008 |
const rowMapping = new Map<number, number>() |
| 1009 |
remainingRowIndexes.forEach((oldIdx, newIdx) => { |
| 1010 |
rowMapping.set(oldIdx, newIdx) |
| 1011 |
}) |
| 1012 |
formBuilderStore.fields.forEach((f) => { |
| 1013 |
const oldIdx = f.rowIndex ?? 0 |
| 1014 |
f.rowIndex = rowMapping.get(oldIdx) ?? oldIdx |
| 1015 |
}) |
| 1016 |
|
| 1017 |
formBuilderStore.updateFieldPositions() |
| 1018 |
} |
| 1019 |
|
| 1020 |
// Helper methods to check if row can move |
| 1021 |
const canMoveRowUp = (row: FieldRow): boolean => { |
| 1022 |
return row.rowIndex > 0 |
| 1023 |
} |
| 1024 |
|
| 1025 |
const canMoveRowDown = (row: FieldRow): boolean => { |
| 1026 |
const maxRow = Math.max(...fields.value.map((f) => f.rowIndex ?? 0)) |
| 1027 |
return row.rowIndex < maxRow |
| 1028 |
} |
| 1029 |
|
| 1030 |
// Field styling |
| 1031 |
const getFieldStyleInRow = (field: Field, rowFields: Field[]) => { |
| 1032 |
const width = field.width ?? 100 |
| 1033 |
|
| 1034 |
// Single field that takes 100% width |
| 1035 |
if (rowFields.length === 1 && width === 100) { |
| 1036 |
return { |
| 1037 |
width: '100%', |
| 1038 |
flexShrink: 0, |
| 1039 |
} |
| 1040 |
} |
| 1041 |
|
| 1042 |
// For multiple fields in a row with gap-12 between them: |
| 1043 |
// With flexbox gap, each field needs to subtract its proportional share of total gap space |
| 1044 |
// Formula: fieldWidth = width% - (totalGaps * width / 100) |
| 1045 |
// Add small adjustment (2px) to keep resize handle inside row border |
| 1046 |
|
| 1047 |
const numGaps = rowFields.length - 1 |
| 1048 |
const totalGapPx = numGaps * 12 // gap-12 = 12px |
| 1049 |
|
| 1050 |
// Calculate proportional gap share for this field |
| 1051 |
const gapShare = (totalGapPx * width) / 100 |
| 1052 |
|
| 1053 |
// Add 2px adjustment to keep resize handle visible inside border |
| 1054 |
const borderAdjustment = 2 |
| 1055 |
|
| 1056 |
return { |
| 1057 |
width: `calc(${width}% - ${gapShare + borderAdjustment}px)`, |
| 1058 |
flexShrink: 0, |
| 1059 |
} |
| 1060 |
} |
| 1061 |
|
| 1062 |
return { |
| 1063 |
// State |
| 1064 |
isDragging, |
| 1065 |
isDraggingRow, |
| 1066 |
draggedField, |
| 1067 |
draggedRow, |
| 1068 |
draggedFromRow, |
| 1069 |
dropIndicator, |
| 1070 |
dropZoneRowIndex, |
| 1071 |
hoveredRowIndex, |
| 1072 |
emptyDropPosition, |
| 1073 |
fieldRows, |
| 1074 |
|
| 1075 |
// Row drag handlers |
| 1076 |
onRowDragStart, |
| 1077 |
onRowDragEnd, |
| 1078 |
|
| 1079 |
// Field drag handlers |
| 1080 |
onFieldDragStart, |
| 1081 |
onFieldDragEnd, |
| 1082 |
|
| 1083 |
// Drop zone handlers |
| 1084 |
onDragOverRowZone, |
| 1085 |
onDragLeaveRowZone, |
| 1086 |
onDropRowZone, |
| 1087 |
|
| 1088 |
// Field drop handlers |
| 1089 |
onDragOverRow, |
| 1090 |
onDragOverField, |
| 1091 |
onDragLeaveField, |
| 1092 |
onDropInRow, |
| 1093 |
onDropOnField, |
| 1094 |
|
| 1095 |
// Rows container handlers |
| 1096 |
onDragOverRowsContainer, |
| 1097 |
onDropInRowsContainer, |
| 1098 |
|
| 1099 |
// Empty state handlers |
| 1100 |
onDragOverEmpty, |
| 1101 |
onDragLeaveEmpty, |
| 1102 |
onDropEmpty, |
| 1103 |
|
| 1104 |
// Row management |
| 1105 |
isRowSelected, |
| 1106 |
moveRowUp, |
| 1107 |
moveRowDown, |
| 1108 |
duplicateRow, |
| 1109 |
deleteRow, |
| 1110 |
canMoveRowUp, |
| 1111 |
canMoveRowDown, |
| 1112 |
|
| 1113 |
// Utilities |
| 1114 |
getFieldStyleInRow, |
| 1115 |
} |
| 1116 |
} |
| 1117 |
|