corrad-bp/components/FormBuilderCanvas.vue
Md Afiq Iskandar 6887a2b9bc Add Form Section Component to Enhance Form Builder Functionality
- Introduced a new Form Section component for grouping related form fields, allowing for better organization and layout within forms.
- Enhanced ComponentPreview to render the Form Section component with customizable properties such as header visibility, collapsibility, and border styles.
- Updated FormBuilderComponents to include the Form Section in the available components list with default properties.
- Implemented drag-and-drop functionality for nested components within the Form Section, improving user experience in form design.
- Enhanced FormBuilderFieldSettingsModal to support configuration options for the Form Section, including visual and spacing settings.
- Improved overall styling and responsiveness of the Form Section component to align with existing UI standards.
- Updated documentation to reflect the addition of the Form Section and its configuration options.
2025-06-24 12:31:21 +08:00

367 lines
11 KiB
Vue

<template>
<div class="form-builder-canvas">
<div v-if="formComponents.length === 0" class="flex flex-col items-center justify-center h-full py-16 text-gray-400">
<Icon name="material-symbols:layers-outline" class="w-16 h-16 mb-4 text-gray-300" />
<div class="text-center max-w-md">
<p class="text-base font-medium text-gray-600 mb-2">Start Building Your Form</p>
<p class="text-sm text-gray-500 mb-4">Drag components from the left sidebar or click on them to add to your form</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 text-left">
<div class="flex items-start">
<Icon name="heroicons:light-bulb" class="w-5 h-5 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
<div>
<p class="text-sm font-medium text-blue-800 mb-1">Quick Start Tips:</p>
<ul class="text-xs text-blue-700 space-y-1">
<li> Add form fields from the sidebar</li>
<li> Click any field to configure its settings</li>
<li> Use the settings button to customize fields</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div v-else class="grid-container">
<draggable
v-model="componentList"
group="form-components"
item-key="id"
handle=".drag-handle"
ghost-class="ghost"
animation="300"
class="draggable-container"
@end="onDragEnd"
>
<template #item="{ element, index }">
<div
class="form-component relative border rounded-md overflow-hidden transition-all duration-200 cursor-pointer"
:class="{
'ring-2 ring-blue-500 bg-blue-50 border-blue-300 shadow-lg': selectedComponentId === element.id,
'bg-white border-gray-200 hover:border-blue-300 hover:shadow-md hover:bg-blue-25': selectedComponentId !== element.id
}"
:style="{
gridColumn: element.props.gridColumn || 'span 12'
}"
@click.capture="selectComponent(element)"
>
<!-- Selection Indicator -->
<div
v-if="selectedComponentId === element.id"
class="absolute top-2 left-2 flex items-center space-x-1 bg-blue-500 text-white text-xs px-2 py-1 rounded-full z-20"
>
<Icon name="heroicons:check-circle" class="w-3 h-3" />
<span>Selected</span>
</div>
<div class="component-actions absolute right-1.5 top-1.5 flex space-x-1 z-10">
<button
class="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Drag to reorder"
>
<span class="drag-handle cursor-move">
<Icon name="material-symbols:drag-indicator" class="w-4 h-4" />
</span>
</button>
<button
class="p-1 text-gray-400 hover:text-gray-600 rounded"
title="Resize component"
@click.stop="toggleResizeMode(element)"
>
<Icon name="material-symbols:resize" class="w-4 h-4" />
</button>
<button
class="p-1 text-gray-400 hover:text-red-500 rounded"
title="Delete component"
@click.stop="deleteComponent(element.id)"
>
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
</button>
</div>
<div class="p-3">
<component-preview
:component="element"
:is-preview="false"
@select-nested-component="handleNestedComponentSelect"
/>
</div>
<!-- Resize handle - only shown when in resize mode -->
<div
v-if="resizeMode && selectedComponentId === element.id"
class="resize-handles"
>
<div
class="resize-handle resize-handle-right"
@mousedown.stop.prevent="startResize($event, element)"
></div>
</div>
</div>
</template>
</draggable>
</div>
</div>
</template>
<script setup>
import draggable from 'vuedraggable';
import { onMounted, watch, onUnmounted, nextTick } from 'vue';
const props = defineProps({
formComponents: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['select-component', 'move-component', 'delete-component', 'update-component', 'optimize-layout', 'select-nested-component']);
const selectedComponentId = ref(null);
const resizeMode = ref(false);
const resizing = ref(false);
const initialWidth = ref(0);
const initialX = ref(0);
// Watch for changes in formComponents
watch(() => props.formComponents, (newComponents) => {
// If the currently selected component is no longer in the list, deselect it
if (selectedComponentId.value &&
!newComponents.some(comp => comp.id === selectedComponentId.value)) {
selectedComponentId.value = null;
resizeMode.value = false;
}
}, { deep: true });
// Create a reactive list with the form components
const componentList = computed({
get: () => props.formComponents,
set: (value) => {
// Do nothing here to avoid reactivity loops - let onDragEnd handle the changes
}
});
// Handle component selection
const selectComponent = (component) => {
if (!component || !component.id) return;
selectedComponentId.value = component.id;
// Use a copy of the component to prevent reactivity issues
// But make sure to keep the original ID
const componentCopy = JSON.parse(JSON.stringify(component));
componentCopy.id = component.id; // Ensure ID preservation
emit('select-component', componentCopy);
};
// Handle component deletion
const deleteComponent = (id) => {
if (selectedComponentId.value === id) {
selectedComponentId.value = null;
resizeMode.value = false;
}
emit('delete-component', id);
};
// Handle nested component selection from sections
const handleNestedComponentSelect = (component) => {
// Pass the nested component selection up to the main form builder
emit('select-nested-component', component);
};
// Toggle resize mode
const toggleResizeMode = (component) => {
resizeMode.value = !resizeMode.value;
selectedComponentId.value = component.id;
};
// Start resizing
const startResize = (event, component) => {
resizing.value = true;
selectedComponentId.value = component.id;
// Store initial values
initialWidth.value = parseInt(component.props.width) || 100;
initialX.value = event.clientX;
// Add event listeners
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
};
// Handle resize event
const handleResize = (event) => {
if (!resizing.value || !selectedComponentId.value) return;
// Calculate new width
const component = props.formComponents.find(c => c.id === selectedComponentId.value);
if (!component) return;
// Calculate delta
const deltaX = event.clientX - initialX.value;
// Convert to percentage of container width
const container = document.querySelector('.grid-container');
if (!container) return;
const containerWidth = container.offsetWidth;
const deltaPercentage = (deltaX / containerWidth) * 100;
// Calculate new width (with constraints)
let newWidth = initialWidth.value + deltaPercentage;
// Constrain to reasonable values
newWidth = Math.max(25, Math.min(100, newWidth)); // Min 25%, max 100%
// Get the current column span
const currentSpanMatch = component.props.gridColumn?.match(/span\s+(\d+)/) || [];
const currentSpan = parseInt(currentSpanMatch[1]) || 12;
// Define standard widths for snap points (25%, 33%, 50%, 66%, 75%, 100%)
const standardWidths = [25, 33, 50, 66, 75, 100];
// Snap to nearest standard width if within 5%
for (const std of standardWidths) {
if (Math.abs(newWidth - std) < 5) {
newWidth = std;
break;
}
}
// Convert precise percentages to exact grid column spans
// This ensures the visual appearance matches the percentage
let gridColumns;
switch (newWidth) {
case 25: gridColumns = 3; break; // 3/12 = 25%
case 33: gridColumns = 4; break; // 4/12 = 33.33%
case 50: gridColumns = 6; break; // 6/12 = 50%
case 66: gridColumns = 8; break; // 8/12 = 66.67%
case 75: gridColumns = 9; break; // 9/12 = 75%
case 100: gridColumns = 12; break; // 12/12 = 100%
default: gridColumns = Math.round((newWidth / 100) * 12);
}
// Only update if the span actually changed to avoid unnecessary rerenders
if (gridColumns !== currentSpan) {
// Update component's width and grid column span
const updatedComponent = {
...component,
props: {
...component.props,
width: `${newWidth}%`,
gridColumn: `span ${gridColumns}`
}
};
// Signal component update
emit('update-component', updatedComponent);
// Signal that a resize has occurred that might require layout optimization
// Using nextTick to ensure the update is processed first
nextTick(() => {
emit('optimize-layout');
});
}
};
// Stop resizing
const stopResize = () => {
resizing.value = false;
// Remove event listeners
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
};
// Handle drag end event for reordering
const onDragEnd = (event) => {
if (event.oldIndex !== event.newIndex) {
emit('move-component', {
oldIndex: event.oldIndex,
newIndex: event.newIndex
});
}
};
// Clean up event listeners when component is unmounted
onUnmounted(() => {
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
});
</script>
<style scoped>
.grid-container {
display: grid;
grid-template-columns: repeat(12, 1fr);
grid-auto-flow: row dense; /* This enables automatic filling of gaps */
column-gap: 16px;
row-gap: 16px;
width: 100%;
padding: 0;
box-sizing: border-box;
}
.draggable-container {
display: contents; /* This makes draggable container not interfere with the grid */
}
.ghost {
opacity: 0.5;
background: #e0f2fe;
border: 1px dashed #60a5fa;
width: 100% !important;
grid-column: span 12 !important;
}
.form-component {
transition: all 0.2s ease;
grid-column: span 12; /* Default to full width */
width: 100% !important; /* Force the width within the grid cell */
margin-bottom: 16px;
}
.form-component:hover .component-actions {
opacity: 1;
}
.component-actions {
opacity: 0;
transition: opacity 0.15s ease;
}
.form-component:hover {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.resize-handles {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
}
.resize-handle {
position: absolute;
pointer-events: auto;
cursor: col-resize;
}
.resize-handle-right {
top: 0;
right: 0;
width: 8px;
height: 100%;
background-color: rgba(37, 99, 235, 0.2);
position: absolute;
cursor: col-resize;
}
.resize-handle-right:hover,
.resize-handle-right:active {
background-color: rgba(37, 99, 235, 0.4);
width: 8px;
}
</style>