PluginProbe
The Innovative Form Builder – IvyForms / 0.8.1
The Innovative Form Builder – IvyForms v0.8.1
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / frontend / src / composables / useFormBuilderDraggable.ts

useFormBuilderDraggable.ts in The Innovative Form Builder – IvyForms 0.8.1, at frontend/src/composables/useFormBuilderDraggable.ts

1,117 lines 35.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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