PluginProbe
The Innovative Form Builder – IvyForms / 0.8
The Innovative Form Builder – IvyForms v0.8
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 / useFieldResize.ts

useFieldResize.ts in The Innovative Form Builder – IvyForms 0.8, at frontend/src/composables/useFieldResize.ts

313 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { ref, onBeforeUnmount } from 'vue'
2 import type { Field } from '@/types'
3 import { useFormBuilder } from '@/stores/useFormBuilder'
4
5 const MIN_WIDTH_PERCENT = 20 // Minimum field width
6 const MAX_WIDTH_PERCENT = 100 // Maximum field width
7
8 // Shared state across all instances (singleton pattern)
9 const resizingField = ref<Field | null>(null)
10 const resizeDirection = ref<'left' | 'right' | null>(null)
11 const resizeStartX = ref(0)
12 const resizeStartWidth = ref(0)
13 const resizeStartRightFieldWidth = ref(0)
14 const hoveredFieldForResize = ref<Field | null>(null)
15
16 // Track active component instances using this composable
17 let activeInstances = 0
18
19 export function useFieldResize() {
20 // Get store instance (initialized after Pinia is available)
21 // The store must be initialized inside the composable function
22 // (after Pinia is available), not at the module level
23 const formBuilderStore = useFormBuilder()
24
25 // Increment instance counter
26 activeInstances++
27
28 /**
29 * Normalize all field widths in a row to ensure they sum to exactly 100%
30 * Only normalizes if fields are close to 100% (within 5%) - otherwise allows empty space
31 * Respects minimum width of 20% per field
32 */
33 const normalizeRowWidths = (rowFields: Field[]): void => {
34 if (rowFields.length === 0) return
35
36 const totalWidth = rowFields.reduce((sum, f) => sum + (f.width ?? 100), 0)
37
38 // Only normalize if fields are CLOSE to 100% (between 95% and 105%)
39 // This means user intended to fill the row, but rounding caused small differences
40 if (totalWidth >= 95 && totalWidth <= 105) {
41 if (totalWidth > 100) {
42 // Scale down proportionally if over 100%
43 const scaleFactor = 100 / totalWidth
44 rowFields.forEach((f) => {
45 f.width = Math.max(MIN_WIDTH_PERCENT, (f.width ?? 100) * scaleFactor)
46 })
47 } else if (totalWidth < 100) {
48 // Add the missing percentage to the last field to reach exactly 100%
49 const difference = 100 - totalWidth
50 const lastField = rowFields[rowFields.length - 1]
51 lastField.width = (lastField.width ?? 100) + difference
52 }
53
54 // Round all widths to 1 decimal place
55 rowFields.forEach((f) => {
56 f.width = Math.round((f.width ?? 100) * 10) / 10
57 })
58
59 // Final check: ensure we're at exactly 100% by adjusting last field for any rounding errors
60 const finalTotal = rowFields.reduce((sum, f) => sum + (f.width ?? 100), 0)
61 if (Math.abs(finalTotal - 100) > 0.01) {
62 const lastField = rowFields[rowFields.length - 1]
63 lastField.width = Math.round(((lastField.width ?? 100) + (100 - finalTotal)) * 10) / 10
64 }
65 } else {
66 // Fields don't sum close to 100%, so user wants empty space - just round the values
67 rowFields.forEach((f) => {
68 f.width = Math.round((f.width ?? 100) * 10) / 10
69 })
70 }
71 }
72
73 /**
74 * Get available space in a row (percentage)
75 */
76 const getAvailableSpace = (rowIndex: number, excludeFieldIndex?: number): number => {
77 const rowFields = formBuilderStore.fields.filter(
78 (f) => f.rowIndex === rowIndex && f.fieldIndex !== excludeFieldIndex,
79 )
80 const usedWidth = rowFields.reduce((sum, f) => sum + (f.width ?? 100), 0)
81 return 100 - usedWidth
82 }
83
84 /**
85 * Start resizing a field
86 */
87 const startResize = (event: MouseEvent, field: Field, direction: 'left' | 'right') => {
88 event.preventDefault()
89 event.stopPropagation()
90
91 resizingField.value = field
92 resizeDirection.value = direction
93 resizeStartX.value = event.clientX
94 resizeStartWidth.value = field.width ?? 100
95
96 // Clear hovered field to prevent other handles from showing
97 hoveredFieldForResize.value = null
98
99 // Add global class to body to keep resize handles visible
100 document.body.classList.add('ivyforms-resizing-field')
101
102 // Set cursor style globally to maintain col-resize cursor
103 document.body.style.cursor = 'col-resize'
104 document.body.style.userSelect = 'none'
105
106 // Find if there's a field to the right and store its initial width
107 const rowFields = formBuilderStore.fields.filter((f) => f.rowIndex === field.rowIndex)
108 rowFields.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0))
109 const currentFieldIndex = rowFields.findIndex((f) => f.fieldIndex === field.fieldIndex)
110 const rightField =
111 currentFieldIndex < rowFields.length - 1 ? rowFields[currentFieldIndex + 1] : null
112
113 resizeStartRightFieldWidth.value = rightField ? (rightField.width ?? 100) : 0
114
115 document.addEventListener('mousemove', handleResize, { passive: false })
116 document.addEventListener('mouseup', stopResize)
117 }
118
119 /**
120 * Handle resize dragging - smooth resize with automatic width distribution
121 */
122 const handleResize = (event: MouseEvent) => {
123 event.preventDefault()
124 event.stopPropagation()
125
126 if (!resizingField.value || resizeDirection.value !== 'right') return
127
128 const deltaX = event.clientX - resizeStartX.value
129 const containerWidth =
130 document.querySelector('.ivyforms-form-builder__grid-container')?.clientWidth ?? 800
131 const deltaPercent = (deltaX / containerWidth) * 100
132
133 const field = resizingField.value
134 const rowFields = formBuilderStore.fields.filter((f) => f.rowIndex === field.rowIndex)
135 rowFields.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0))
136
137 const currentFieldIndex = rowFields.findIndex((f) => f.fieldIndex === field.fieldIndex)
138 const rightField =
139 currentFieldIndex < rowFields.length - 1 ? rowFields[currentFieldIndex + 1] : null
140
141 // Calculate new width
142 let newWidth = resizeStartWidth.value + deltaPercent
143
144 // Clamp to min/max
145 newWidth = Math.max(MIN_WIDTH_PERCENT, Math.min(MAX_WIDTH_PERCENT, newWidth))
146
147 if (rightField) {
148 // Resize between two fields - take from right field
149 const totalWidth = resizeStartWidth.value + resizeStartRightFieldWidth.value
150 const newRightWidth = totalWidth - newWidth
151
152 // Ensure both fields stay within valid range
153 if (newRightWidth >= MIN_WIDTH_PERCENT) {
154 field.width = Math.round(newWidth * 10) / 10 // Round to 1 decimal
155 rightField.width = Math.round(newRightWidth * 10) / 10
156 }
157 } else {
158 // Last field in row: can resize into empty space
159 // Calculate space used by OTHER fields (excluding current field)
160 const otherFieldsWidth = rowFields
161 .filter((f) => f.fieldIndex !== field.fieldIndex)
162 .reduce((sum, f) => sum + (f.width ?? 100), 0)
163
164 // Maximum width is 100% minus other fields' widths
165 const maxAllowedWidth = 100 - otherFieldsWidth
166
167 // Clamp to valid range
168 newWidth = Math.max(MIN_WIDTH_PERCENT, Math.min(maxAllowedWidth, newWidth))
169 field.width = Math.round(newWidth * 10) / 10
170 }
171 }
172
173 /**
174 * Stop resizing
175 */
176 const stopResize = () => {
177 // Remove global resizing class and cursor styles
178 document.body.classList.remove('ivyforms-resizing-field')
179 document.body.style.cursor = ''
180 document.body.style.userSelect = ''
181
182 if (resizingField.value) {
183 // Get all fields in the row
184 const rowFields = formBuilderStore.fields.filter(
185 (f) => f.rowIndex === resizingField.value!.rowIndex,
186 )
187
188 // Normalize widths to ensure they fit within 100%
189 normalizeRowWidths(rowFields)
190
191 // Update field positions
192 formBuilderStore.updateFieldPositions()
193 }
194
195 resizingField.value = null
196 resizeDirection.value = null
197 resizeStartRightFieldWidth.value = 0
198
199 document.removeEventListener('mousemove', handleResize)
200 document.removeEventListener('mouseup', stopResize)
201 }
202
203 /**
204 * Cleanup function to ensure event listeners are removed
205 */
206 const cleanup = () => {
207 // Decrement instance counter
208 activeInstances--
209
210 // If this was the last instance, clean up all shared state and listeners
211 if (activeInstances === 0) {
212 // Remove any active event listeners
213 document.removeEventListener('mousemove', handleResize)
214 document.removeEventListener('mouseup', stopResize)
215
216 // Clean up DOM modifications
217 document.body.classList.remove('ivyforms-resizing-field')
218 document.body.style.cursor = ''
219 document.body.style.userSelect = ''
220
221 // Reset shared state
222 resizingField.value = null
223 resizeDirection.value = null
224 resizeStartX.value = 0
225 resizeStartWidth.value = 0
226 resizeStartRightFieldWidth.value = 0
227 hoveredFieldForResize.value = null
228 }
229 }
230
231 // Setup cleanup on component unmount
232 onBeforeUnmount(cleanup)
233
234 /**
235 * Set hovered field for showing resize handles
236 */
237 const setHoveredField = (field: Field | null) => {
238 // Don't update hover state if currently resizing
239 if (resizingField.value) {
240 return
241 }
242
243 hoveredFieldForResize.value = field
244 }
245
246 /**
247 * Check if resize handles should be shown for a field
248 */
249 const shouldShowResizeHandles = (field: Field): boolean => {
250 const formBuilderStore = useFormBuilder()
251
252 // Check if row has 5 fields (all at minimum 20%, can't resize)
253 const rowFields = formBuilderStore.fields.filter((f) => f.rowIndex === field.rowIndex)
254 if (rowFields.length >= 5) {
255 return false // Hide resize handles when row is at maximum capacity
256 }
257
258 // If actively resizing, ONLY show handle for the field being resized
259 if (resizingField.value) {
260 return resizingField.value.fieldIndex === field.fieldIndex
261 }
262
263 // Otherwise show handles if field is hovered
264 return hoveredFieldForResize.value?.fieldIndex === field.fieldIndex
265 }
266
267 /**
268 * Calculate width for new field dropped into empty space
269 */
270 const getWidthForDropInEmptySpace = (rowIndex: number): number => {
271 const availableSpace = getAvailableSpace(rowIndex)
272 return Math.max(MIN_WIDTH_PERCENT, Math.min(MAX_WIDTH_PERCENT, availableSpace))
273 }
274
275 /**
276 * Check if a field can be duplicated (will it fit in the row)
277 */
278 const canDuplicateField = (field: Field): boolean => {
279 const availableSpace = getAvailableSpace(field.rowIndex ?? 0)
280 return availableSpace >= MIN_WIDTH_PERCENT
281 }
282
283 /**
284 * Get the width for a duplicated field
285 */
286 const getWidthForDuplicate = (field: Field): number => {
287 const fieldWidth = field.width ?? 100
288 const availableSpace = getAvailableSpace(field.rowIndex ?? 0)
289
290 if (availableSpace >= fieldWidth) {
291 return fieldWidth
292 } else {
293 return Math.max(MIN_WIDTH_PERCENT, availableSpace)
294 }
295 }
296
297 return {
298 // State - return raw refs, not computed
299 resizingField,
300 hoveredFieldForResize,
301
302 // Methods
303 startResize,
304 setHoveredField,
305 shouldShowResizeHandles,
306 getAvailableSpace,
307 getWidthForDropInEmptySpace,
308 canDuplicateField,
309 getWidthForDuplicate,
310 normalizeRowWidths,
311 }
312 }
313