PluginProbe
The Innovative Form Builder – IvyForms / 1.1
The Innovative Form Builder – IvyForms v1.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 1.1, at frontend/src/composables/useFormBuilderDraggable.ts

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