Enhance FormBuilderFieldSettingsModal with Repeating Group Configuration
- Added configuration options for repeating groups, including minimum and maximum item limits, button text settings, and dynamic field management. - Implemented functionality to add and remove fields within the repeating group, improving user experience for form customization. - Updated component type and description mappings to include the new repeating group feature, ensuring consistency across the application.
This commit is contained in:
parent
0060ad38b7
commit
9883504a7c
@ -693,6 +693,173 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Repeating Group Configuration -->
|
||||||
|
<template v-if="component.type === 'repeating-group'">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Group Limits -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<FormKit
|
||||||
|
type="number"
|
||||||
|
label="Minimum Items"
|
||||||
|
name="minItems"
|
||||||
|
v-model="configModel.minItems"
|
||||||
|
help="Minimum number of groups required"
|
||||||
|
:classes="{ outer: 'field-wrapper' }"
|
||||||
|
:min="0"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="number"
|
||||||
|
label="Maximum Items"
|
||||||
|
name="maxItems"
|
||||||
|
v-model="configModel.maxItems"
|
||||||
|
help="Maximum number of groups allowed"
|
||||||
|
:classes="{ outer: 'field-wrapper' }"
|
||||||
|
:min="1"
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Button Text Settings -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<FormKit
|
||||||
|
type="text"
|
||||||
|
label="Add Button Text"
|
||||||
|
name="buttonText"
|
||||||
|
v-model="configModel.buttonText"
|
||||||
|
help="Text for the add button"
|
||||||
|
:classes="{ outer: 'field-wrapper' }"
|
||||||
|
placeholder="Add Item"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="text"
|
||||||
|
label="Remove Button Text"
|
||||||
|
name="removeText"
|
||||||
|
v-model="configModel.removeText"
|
||||||
|
help="Text for the remove button"
|
||||||
|
:classes="{ outer: 'field-wrapper' }"
|
||||||
|
placeholder="Remove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Fields Management -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Group Fields</h5>
|
||||||
|
|
||||||
|
<div class="border rounded-md p-3 bg-gray-50 space-y-3">
|
||||||
|
<div v-for="(field, index) in (configModel.fields || [])" :key="index" class="border p-3 rounded bg-white">
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<h4 class="font-medium text-sm text-gray-800">Field {{ index + 1 }}</h4>
|
||||||
|
<button
|
||||||
|
@click="removeGroupField(index)"
|
||||||
|
class="text-red-500 hover:text-red-700 p-1"
|
||||||
|
type="button"
|
||||||
|
title="Remove field"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:delete-outline" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||||
|
<FormKit
|
||||||
|
type="select"
|
||||||
|
label="Field Type"
|
||||||
|
:options="[
|
||||||
|
{ label: 'Text', value: 'text' },
|
||||||
|
{ label: 'Number', value: 'number' },
|
||||||
|
{ label: 'Email', value: 'email' },
|
||||||
|
{ label: 'Textarea', value: 'textarea' },
|
||||||
|
{ label: 'Select', value: 'select' },
|
||||||
|
{ label: 'Date', value: 'date' },
|
||||||
|
{ label: 'Time', value: 'time' },
|
||||||
|
{ label: 'Checkbox', value: 'checkbox' },
|
||||||
|
{ label: 'Radio', value: 'radio' }
|
||||||
|
]"
|
||||||
|
v-model="field.type"
|
||||||
|
:classes="{ outer: 'field-wrapper' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="text"
|
||||||
|
label="Field Name"
|
||||||
|
v-model="field.name"
|
||||||
|
:classes="{ outer: 'field-wrapper' }"
|
||||||
|
placeholder="e.g., name, age, email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<FormKit
|
||||||
|
type="text"
|
||||||
|
label="Field Label"
|
||||||
|
v-model="field.label"
|
||||||
|
:classes="{ outer: 'field-wrapper' }"
|
||||||
|
placeholder="e.g., Full Name, Age, Email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="text"
|
||||||
|
label="Placeholder"
|
||||||
|
v-model="field.placeholder"
|
||||||
|
:classes="{ outer: 'field-wrapper' }"
|
||||||
|
placeholder="e.g., Enter your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Options for select/radio/checkbox fields -->
|
||||||
|
<div v-if="['select', 'radio', 'checkbox'].includes(field.type)" class="mt-3">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Options</label>
|
||||||
|
<div class="border rounded-md p-2 bg-gray-50 space-y-2">
|
||||||
|
<div v-for="(option, optionIndex) in (field.options || [])" :key="optionIndex" class="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="field.options[optionIndex].label"
|
||||||
|
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
|
||||||
|
placeholder="Option label"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="field.options[optionIndex].value"
|
||||||
|
class="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
|
||||||
|
placeholder="Option value"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="removeFieldOption(field, optionIndex)"
|
||||||
|
class="text-red-500 hover:text-red-700 p-1"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:delete-outline" class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="addFieldOption(field)"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800 flex items-center"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:add-circle-outline" class="w-3 h-3 mr-1" />
|
||||||
|
Add Option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="addGroupField"
|
||||||
|
class="w-full text-sm text-blue-600 hover:text-blue-800 flex items-center justify-center py-2 border border-dashed border-blue-300 rounded-md hover:bg-blue-50"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:add-circle-outline" class="w-4 h-4 mr-1" />
|
||||||
|
Add Field to Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Form Section Configuration -->
|
<!-- Form Section Configuration -->
|
||||||
<template v-if="component.type === 'form-section'">
|
<template v-if="component.type === 'form-section'">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@ -1463,7 +1630,8 @@ const getComponentIcon = (type) => {
|
|||||||
'form-section': 'material-symbols:view-module-outline',
|
'form-section': 'material-symbols:view-module-outline',
|
||||||
'info-display': 'heroicons:information-circle',
|
'info-display': 'heroicons:information-circle',
|
||||||
'dynamic-list': 'heroicons:list-bullet',
|
'dynamic-list': 'heroicons:list-bullet',
|
||||||
'repeating-table': 'heroicons:table-cells'
|
'repeating-table': 'heroicons:table-cells',
|
||||||
|
'repeating-group': 'heroicons:list-bullet'
|
||||||
}
|
}
|
||||||
return icons[type] || 'heroicons:square-3-stack-3d'
|
return icons[type] || 'heroicons:square-3-stack-3d'
|
||||||
}
|
}
|
||||||
@ -1498,7 +1666,8 @@ const getComponentTypeName = (type) => {
|
|||||||
'form-section': 'Form Section',
|
'form-section': 'Form Section',
|
||||||
'info-display': 'Information Display',
|
'info-display': 'Information Display',
|
||||||
'dynamic-list': 'Dynamic List',
|
'dynamic-list': 'Dynamic List',
|
||||||
'repeating-table': 'Data Table'
|
'repeating-table': 'Data Table',
|
||||||
|
'repeating-group': 'Repeating Group'
|
||||||
}
|
}
|
||||||
return names[type] || 'Form Field'
|
return names[type] || 'Form Field'
|
||||||
}
|
}
|
||||||
@ -1533,7 +1702,8 @@ const getComponentDescription = (type) => {
|
|||||||
'form-section': 'Visual container to group related form fields into sections',
|
'form-section': 'Visual container to group related form fields into sections',
|
||||||
'info-display': 'Read-only information display in organized format',
|
'info-display': 'Read-only information display in organized format',
|
||||||
'dynamic-list': 'Dynamic list for displaying and managing items',
|
'dynamic-list': 'Dynamic list for displaying and managing items',
|
||||||
'repeating-table': 'Structured table for collecting multiple records with forms'
|
'repeating-table': 'Structured table for collecting multiple records with forms',
|
||||||
|
'repeating-group': 'Collect multiple entries of the same data structure'
|
||||||
}
|
}
|
||||||
return descriptions[type] || 'Configure this form field'
|
return descriptions[type] || 'Configure this form field'
|
||||||
}
|
}
|
||||||
@ -1558,14 +1728,14 @@ const showField = (fieldName) => {
|
|||||||
if (!props.component) return false
|
if (!props.component) return false
|
||||||
|
|
||||||
const fieldConfig = {
|
const fieldConfig = {
|
||||||
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
|
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
|
||||||
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
|
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
|
||||||
placeholder: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'dynamic-list'],
|
placeholder: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'dynamic-list'],
|
||||||
help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table'],
|
help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
|
||||||
value: ['heading', 'paragraph', 'hidden'],
|
value: ['heading', 'paragraph', 'hidden'],
|
||||||
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'form-section', 'info-display', 'dynamic-list', 'repeating-table'],
|
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'heading', 'paragraph', 'form-section', 'info-display', 'dynamic-list', 'repeating-table', 'repeating-group'],
|
||||||
options: ['select', 'checkbox', 'radio'],
|
options: ['select', 'checkbox', 'radio'],
|
||||||
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table']
|
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group']
|
||||||
}
|
}
|
||||||
|
|
||||||
return fieldConfig[fieldName]?.includes(props.component.type) || false
|
return fieldConfig[fieldName]?.includes(props.component.type) || false
|
||||||
@ -1574,7 +1744,7 @@ const showField = (fieldName) => {
|
|||||||
const hasOptions = computed(() => showField('options'))
|
const hasOptions = computed(() => showField('options'))
|
||||||
const hasSpecificSettings = computed(() => {
|
const hasSpecificSettings = computed(() => {
|
||||||
if (!props.component) return false
|
if (!props.component) return false
|
||||||
const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'button', 'form-section', 'dynamic-list', 'repeating-table']
|
const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group']
|
||||||
return specificTypes.includes(props.component.type)
|
return specificTypes.includes(props.component.type)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -2031,6 +2201,42 @@ const removeNestedComponent = (index) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repeating group field management
|
||||||
|
const addGroupField = () => {
|
||||||
|
if (!configModel.value.fields) {
|
||||||
|
configModel.value.fields = []
|
||||||
|
}
|
||||||
|
|
||||||
|
configModel.value.fields.push({
|
||||||
|
type: 'text',
|
||||||
|
name: `field_${configModel.value.fields.length + 1}`,
|
||||||
|
label: `Field ${configModel.value.fields.length + 1}`,
|
||||||
|
placeholder: 'Enter value'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeGroupField = (index) => {
|
||||||
|
if (configModel.value.fields) {
|
||||||
|
configModel.value.fields.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addFieldOption = (field) => {
|
||||||
|
if (!field.options) {
|
||||||
|
field.options = []
|
||||||
|
}
|
||||||
|
field.options.push({
|
||||||
|
label: `Option ${field.options.length + 1}`,
|
||||||
|
value: `option_${field.options.length + 1}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFieldOption = (field, optionIndex) => {
|
||||||
|
if (field.options) {
|
||||||
|
field.options.splice(optionIndex, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Type changing methods
|
// Type changing methods
|
||||||
const handleTypeChange = (newType) => {
|
const handleTypeChange = (newType) => {
|
||||||
if (newType === props.component.type) return
|
if (newType === props.component.type) return
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const emits = defineEmits(["update:modelValue"]);
|
const emits = defineEmits(["update:modelValue", "close"]);
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
@ -65,11 +65,15 @@ const props = defineProps({
|
|||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
emits("update:modelValue", false);
|
emits("update:modelValue", false);
|
||||||
|
emits("close");
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateCancelCallback = () => {
|
const validateCancelCallback = () => {
|
||||||
if (props.cancelCallback == "() => {}") closeModal();
|
if (props.cancelCallback == "() => {}") closeModal();
|
||||||
else props.cancelCallback();
|
else {
|
||||||
|
props.cancelCallback();
|
||||||
|
emits("close");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -131,7 +135,7 @@ watch(
|
|||||||
>
|
>
|
||||||
<rs-button
|
<rs-button
|
||||||
v-if="!$slots.footer && !cancelOnly"
|
v-if="!$slots.footer && !cancelOnly"
|
||||||
@click="okCallback"
|
@click="() => { okCallback(); emits('close'); }"
|
||||||
>{{ okTitle }}</rs-button
|
>{{ okTitle }}</rs-button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1225,6 +1225,72 @@ defineExpose({
|
|||||||
get flowInstance() {
|
get flowInstance() {
|
||||||
return flowInstance;
|
return flowInstance;
|
||||||
},
|
},
|
||||||
|
// Provide direct access to nodes and edges
|
||||||
|
get nodes() {
|
||||||
|
return nodes.value;
|
||||||
|
},
|
||||||
|
get edges() {
|
||||||
|
return edges.value;
|
||||||
|
},
|
||||||
|
// Add method to get selected elements
|
||||||
|
getSelectedElements: () => {
|
||||||
|
try {
|
||||||
|
// First try to get selection from Vue Flow instance
|
||||||
|
if (flowInstance) {
|
||||||
|
const selection = flowInstance.getSelection();
|
||||||
|
if (selection && (selection.nodes?.length > 0 || selection.edges?.length > 0)) {
|
||||||
|
return {
|
||||||
|
nodes: selection.nodes || [],
|
||||||
|
edges: selection.edges || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check for selected property on nodes and edges
|
||||||
|
const selectedNodes = nodes.value.filter(node => node.selected === true);
|
||||||
|
const selectedEdges = edges.value.filter(edge => edge.selected === true);
|
||||||
|
|
||||||
|
// If still no selection, try DOM-based detection
|
||||||
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
||||||
|
// Look for selected nodes in the DOM
|
||||||
|
const selectedNodeElements = document.querySelectorAll('.vue-flow__node.selected');
|
||||||
|
const selectedEdgeElements = document.querySelectorAll('.vue-flow__edge.selected');
|
||||||
|
|
||||||
|
// Convert DOM elements to node/edge data
|
||||||
|
const domSelectedNodes = [];
|
||||||
|
const domSelectedEdges = [];
|
||||||
|
|
||||||
|
selectedNodeElements.forEach(element => {
|
||||||
|
const nodeId = element.getAttribute('data-id');
|
||||||
|
if (nodeId) {
|
||||||
|
const node = nodes.value.find(n => n.id === nodeId);
|
||||||
|
if (node) {
|
||||||
|
domSelectedNodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedEdgeElements.forEach(element => {
|
||||||
|
const edgeId = element.getAttribute('data-id');
|
||||||
|
if (edgeId) {
|
||||||
|
const edge = edges.value.find(e => e.id === edgeId);
|
||||||
|
if (edge) {
|
||||||
|
domSelectedEdges.push(edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (domSelectedNodes.length > 0 || domSelectedEdges.length > 0) {
|
||||||
|
return { nodes: domSelectedNodes, edges: domSelectedEdges };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes: selectedNodes, edges: selectedEdges };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Error getting selected elements:', error);
|
||||||
|
return { nodes: [], edges: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update an existing node
|
// Update an existing node
|
||||||
|
@ -2369,7 +2369,7 @@ const getDefaultIcon = (type) => {
|
|||||||
'file': 'material-symbols:upload-file-outline',
|
'file': 'material-symbols:upload-file-outline',
|
||||||
'button': 'material-symbols:smart-button',
|
'button': 'material-symbols:smart-button',
|
||||||
'image-preview': 'material-symbols:image-outline',
|
'image-preview': 'material-symbols:image-outline',
|
||||||
'repeating-group': 'material-symbols:playlist-add-outline',
|
'repeating-group': 'heroicons:list-bullet',
|
||||||
'dynamic-list': 'material-symbols:format-list-bulleted'
|
'dynamic-list': 'material-symbols:format-list-bulleted'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -79,6 +79,52 @@ const navigationConfirmed = ref(false);
|
|||||||
const isSaving = ref(false);
|
const isSaving = ref(false);
|
||||||
const isAddingComponent = ref(false); // Flag to prevent canvas reactivity issues during component addition
|
const isAddingComponent = ref(false); // Flag to prevent canvas reactivity issues during component addition
|
||||||
|
|
||||||
|
// Add clipboard state for copy/paste functionality
|
||||||
|
const clipboard = ref({
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
type: null, // 'nodes', 'edges', or 'mixed'
|
||||||
|
timestamp: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load clipboard from localStorage on mount
|
||||||
|
const loadClipboardFromStorage = () => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('processBuilderClipboard');
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
// Only restore if it's recent (within 24 hours)
|
||||||
|
const isRecent = parsed.timestamp && (Date.now() - parsed.timestamp) < 24 * 60 * 60 * 1000;
|
||||||
|
if (isRecent) {
|
||||||
|
clipboard.value = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load clipboard from storage:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save clipboard to localStorage
|
||||||
|
const saveClipboardToStorage = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('processBuilderClipboard', JSON.stringify(clipboard.value));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save clipboard to storage:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Storage event handler for cross-tab clipboard sync
|
||||||
|
const handleStorageEvent = (event) => {
|
||||||
|
if (event.key === 'processBuilderClipboard' && event.newValue) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(event.newValue);
|
||||||
|
clipboard.value = parsed;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse clipboard from storage event:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Add a ref for the ProcessFlowCanvas component
|
// Add a ref for the ProcessFlowCanvas component
|
||||||
const processFlowCanvas = ref(null);
|
const processFlowCanvas = ref(null);
|
||||||
|
|
||||||
@ -168,7 +214,344 @@ const onPaneClickMobile = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keyboard shortcuts for panel toggles
|
// Copy/paste functions
|
||||||
|
const copySelectedElements = () => {
|
||||||
|
const copiedNodes = [];
|
||||||
|
const copiedEdges = [];
|
||||||
|
|
||||||
|
// Get all selected elements from Vue Flow
|
||||||
|
let selectedNodes = [];
|
||||||
|
let selectedEdges = [];
|
||||||
|
|
||||||
|
if (processFlowCanvas.value) {
|
||||||
|
try {
|
||||||
|
// Try the new getSelectedElements method first
|
||||||
|
if (processFlowCanvas.value.getSelectedElements) {
|
||||||
|
const selection = processFlowCanvas.value.getSelectedElements();
|
||||||
|
selectedNodes = selection.nodes || [];
|
||||||
|
selectedEdges = selection.edges || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no selection found, try direct access to nodes and edges
|
||||||
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
||||||
|
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
|
||||||
|
selectedNodes = processFlowCanvas.value.nodes.filter(node => node.selected === true);
|
||||||
|
selectedEdges = processFlowCanvas.value.edges.filter(edge => edge.selected === true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still no selection found, try DOM-based detection as fallback
|
||||||
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
||||||
|
// Look for selected nodes in the DOM
|
||||||
|
const selectedNodeElements = document.querySelectorAll('.vue-flow__node.selected');
|
||||||
|
const selectedEdgeElements = document.querySelectorAll('.vue-flow__edge.selected');
|
||||||
|
|
||||||
|
// Convert DOM elements to node/edge data
|
||||||
|
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
|
||||||
|
selectedNodeElements.forEach(element => {
|
||||||
|
const nodeId = element.getAttribute('data-id');
|
||||||
|
if (nodeId) {
|
||||||
|
const node = processFlowCanvas.value.nodes.find(n => n.id === nodeId);
|
||||||
|
if (node) {
|
||||||
|
selectedNodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedEdgeElements.forEach(element => {
|
||||||
|
const edgeId = element.getAttribute('data-id');
|
||||||
|
if (edgeId) {
|
||||||
|
const edge = processFlowCanvas.value.edges.find(e => e.id === edgeId);
|
||||||
|
if (edge) {
|
||||||
|
selectedEdges.push(edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to get selected elements from Vue Flow:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no multiple selection, fall back to single selection
|
||||||
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
||||||
|
// Copy selected nodes
|
||||||
|
if (selectedNodeData.value) {
|
||||||
|
const nodeCopy = JSON.parse(JSON.stringify(selectedNodeData.value));
|
||||||
|
// Remove any temporary properties
|
||||||
|
delete nodeCopy.selected;
|
||||||
|
delete nodeCopy.dragging;
|
||||||
|
copiedNodes.push(nodeCopy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy selected edges
|
||||||
|
if (selectedEdgeData.value) {
|
||||||
|
const edgeCopy = JSON.parse(JSON.stringify(selectedEdgeData.value));
|
||||||
|
// Remove any temporary properties
|
||||||
|
delete edgeCopy.selected;
|
||||||
|
copiedEdges.push(edgeCopy);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle multiple selection
|
||||||
|
for (const node of selectedNodes) {
|
||||||
|
const nodeCopy = JSON.parse(JSON.stringify(node));
|
||||||
|
// Remove any temporary properties
|
||||||
|
delete nodeCopy.selected;
|
||||||
|
delete nodeCopy.dragging;
|
||||||
|
copiedNodes.push(nodeCopy);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const edge of selectedEdges) {
|
||||||
|
const edgeCopy = JSON.parse(JSON.stringify(edge));
|
||||||
|
// Remove any temporary properties
|
||||||
|
delete edgeCopy.selected;
|
||||||
|
copiedEdges.push(edgeCopy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in clipboard
|
||||||
|
clipboard.value = {
|
||||||
|
nodes: copiedNodes,
|
||||||
|
edges: copiedEdges,
|
||||||
|
type: copiedNodes.length > 0 && copiedEdges.length > 0 ? 'mixed' :
|
||||||
|
copiedNodes.length > 0 ? 'nodes' :
|
||||||
|
copiedEdges.length > 0 ? 'edges' : null,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
saveClipboardToStorage();
|
||||||
|
|
||||||
|
// Show feedback
|
||||||
|
const totalElements = copiedNodes.length + copiedEdges.length;
|
||||||
|
if (totalElements > 0) {
|
||||||
|
toast.success(`Copied ${totalElements} element${totalElements > 1 ? 's' : ''} to clipboard`);
|
||||||
|
} else {
|
||||||
|
toast.warning('No elements selected to copy');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearClipboard = () => {
|
||||||
|
clipboard.value = {
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
type: null,
|
||||||
|
timestamp: null
|
||||||
|
};
|
||||||
|
// Clear from localStorage
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('processBuilderClipboard');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to clear clipboard from storage:', error);
|
||||||
|
}
|
||||||
|
toast.info('Clipboard cleared');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug function to help understand selection state
|
||||||
|
const debugSelection = () => {
|
||||||
|
console.log('=== DEBUG SELECTION STATE ===');
|
||||||
|
|
||||||
|
if (!processFlowCanvas.value) {
|
||||||
|
console.log('ProcessFlowCanvas not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('ProcessFlowCanvas available, checking properties...');
|
||||||
|
console.log('Available methods:', Object.keys(processFlowCanvas.value));
|
||||||
|
|
||||||
|
if (!processFlowCanvas.value.flowInstance) {
|
||||||
|
console.log('Flow instance not available');
|
||||||
|
console.log('Trying to access flowInstance directly...');
|
||||||
|
|
||||||
|
// Try different ways to access the flow instance
|
||||||
|
try {
|
||||||
|
const flowInstance = processFlowCanvas.value.flowInstance;
|
||||||
|
console.log('Direct access result:', flowInstance);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Direct access error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we can access nodes and edges directly
|
||||||
|
try {
|
||||||
|
const nodes = processFlowCanvas.value.nodes;
|
||||||
|
const edges = processFlowCanvas.value.edges;
|
||||||
|
console.log('Direct nodes access:', nodes);
|
||||||
|
console.log('Direct edges access:', edges);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Direct nodes/edges access error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowInstance = processFlowCanvas.value.flowInstance;
|
||||||
|
|
||||||
|
// Check Vue Flow selection
|
||||||
|
try {
|
||||||
|
const selection = flowInstance.getSelection();
|
||||||
|
console.log('Vue Flow selection:', selection);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error getting Vue Flow selection:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all nodes and edges
|
||||||
|
const allNodes = flowInstance.getNodes();
|
||||||
|
const allEdges = flowInstance.getEdges();
|
||||||
|
|
||||||
|
console.log('All nodes:', allNodes.length);
|
||||||
|
console.log('All edges:', allEdges.length);
|
||||||
|
|
||||||
|
// Check for selected property
|
||||||
|
const selectedNodes = allNodes.filter(node => node.selected);
|
||||||
|
const selectedEdges = allEdges.filter(edge => edge.selected);
|
||||||
|
|
||||||
|
console.log('Nodes with selected=true:', selectedNodes.length);
|
||||||
|
console.log('Edges with selected=true:', selectedEdges.length);
|
||||||
|
|
||||||
|
// Check DOM
|
||||||
|
const domSelectedNodes = document.querySelectorAll('.vue-flow__node.selected');
|
||||||
|
const domSelectedEdges = document.querySelectorAll('.vue-flow__edge.selected');
|
||||||
|
|
||||||
|
console.log('DOM selected nodes:', domSelectedNodes.length);
|
||||||
|
console.log('DOM selected edges:', domSelectedEdges.length);
|
||||||
|
|
||||||
|
// Show details of selected elements
|
||||||
|
if (selectedNodes.length > 0) {
|
||||||
|
console.log('Selected nodes:', selectedNodes.map(n => ({ id: n.id, type: n.type, selected: n.selected })));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedEdges.length > 0) {
|
||||||
|
console.log('Selected edges:', selectedEdges.map(e => ({ id: e.id, source: e.source, target: e.target, selected: e.selected })));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== END DEBUG ===');
|
||||||
|
};
|
||||||
|
|
||||||
|
const pasteElements = async () => {
|
||||||
|
if (!clipboard.value.type || !processStore.currentProcess) {
|
||||||
|
toast.warning('No elements in clipboard to paste');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pastedNodes = [];
|
||||||
|
const pastedEdges = [];
|
||||||
|
const nodeIdMap = new Map(); // Map old IDs to new IDs
|
||||||
|
|
||||||
|
// Process nodes first
|
||||||
|
if (clipboard.value.nodes.length > 0) {
|
||||||
|
for (const node of clipboard.value.nodes) {
|
||||||
|
const newNode = JSON.parse(JSON.stringify(node));
|
||||||
|
|
||||||
|
// Generate new ID
|
||||||
|
const oldId = newNode.id;
|
||||||
|
const newId = `${newNode.type}_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
||||||
|
newNode.id = newId;
|
||||||
|
|
||||||
|
// Offset position to avoid overlap - use a more sophisticated offset
|
||||||
|
const baseX = newNode.position?.x || 100;
|
||||||
|
const baseY = newNode.position?.y || 100;
|
||||||
|
const offsetX = 100 + (pastedNodes.length * 80); // Larger offset for better visibility
|
||||||
|
const offsetY = 100 + (pastedNodes.length * 60); // Different Y offset to create a diagonal pattern
|
||||||
|
|
||||||
|
newNode.position = {
|
||||||
|
x: baseX + offsetX,
|
||||||
|
y: baseY + offsetY
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update label to indicate it's a copy
|
||||||
|
if (newNode.label && !newNode.label.includes(' (Copy)')) {
|
||||||
|
newNode.label = `${newNode.label} (Copy)`;
|
||||||
|
}
|
||||||
|
if (newNode.data?.label && !newNode.data.label.includes(' (Copy)')) {
|
||||||
|
newNode.data.label = `${newNode.data.label} (Copy)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store ID mapping for edge updates
|
||||||
|
nodeIdMap.set(oldId, newId);
|
||||||
|
|
||||||
|
// Add to store
|
||||||
|
await processStore.addNode(newNode);
|
||||||
|
pastedNodes.push(newNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process edges after nodes
|
||||||
|
if (clipboard.value.edges.length > 0) {
|
||||||
|
for (const edge of clipboard.value.edges) {
|
||||||
|
const newEdge = JSON.parse(JSON.stringify(edge));
|
||||||
|
|
||||||
|
// Generate new ID
|
||||||
|
newEdge.id = `edge_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
||||||
|
|
||||||
|
// Update source and target IDs if they were copied nodes
|
||||||
|
if (nodeIdMap.has(newEdge.source)) {
|
||||||
|
newEdge.source = nodeIdMap.get(newEdge.source);
|
||||||
|
}
|
||||||
|
if (nodeIdMap.has(newEdge.target)) {
|
||||||
|
newEdge.target = nodeIdMap.get(newEdge.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add edge if both source and target nodes exist
|
||||||
|
const sourceExists = processStore.currentProcess.nodes.some(n => n.id === newEdge.source);
|
||||||
|
const targetExists = processStore.currentProcess.nodes.some(n => n.id === newEdge.target);
|
||||||
|
|
||||||
|
if (sourceExists && targetExists) {
|
||||||
|
await processStore.addEdge(newEdge);
|
||||||
|
pastedEdges.push(newEdge);
|
||||||
|
} else {
|
||||||
|
// Show warning for orphaned edges
|
||||||
|
const missingNodes = [];
|
||||||
|
if (!sourceExists) missingNodes.push(newEdge.source);
|
||||||
|
if (!targetExists) missingNodes.push(newEdge.target);
|
||||||
|
console.warn(`Skipping edge ${newEdge.id}: missing nodes ${missingNodes.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
const totalPasted = pastedNodes.length + pastedEdges.length;
|
||||||
|
const skippedEdges = clipboard.value.edges.length - pastedEdges.length;
|
||||||
|
|
||||||
|
if (totalPasted > 0) {
|
||||||
|
let message = `Pasted ${totalPasted} element${totalPasted > 1 ? 's' : ''} successfully`;
|
||||||
|
if (skippedEdges > 0) {
|
||||||
|
message += ` (${skippedEdges} edge${skippedEdges > 1 ? 's' : ''} skipped - missing nodes)`;
|
||||||
|
}
|
||||||
|
toast.success(message);
|
||||||
|
|
||||||
|
// Select the first pasted node if any
|
||||||
|
if (pastedNodes.length > 0) {
|
||||||
|
onNodeSelected(pastedNodes[0]);
|
||||||
|
|
||||||
|
// Fit view to show all pasted nodes
|
||||||
|
if (processFlowCanvas.value && processFlowCanvas.value.fitView) {
|
||||||
|
nextTick(() => {
|
||||||
|
try {
|
||||||
|
processFlowCanvas.value.fitView({
|
||||||
|
padding: 0.1,
|
||||||
|
includeHiddenNodes: false,
|
||||||
|
minZoom: 0.5,
|
||||||
|
maxZoom: 1.5
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fit view:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (skippedEdges > 0) {
|
||||||
|
toast.warning(`${skippedEdges} edge${skippedEdges > 1 ? 's' : ''} could not be pasted - missing connected nodes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error pasting elements:', error);
|
||||||
|
toast.error('Failed to paste elements');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcuts for panel toggles and copy/paste
|
||||||
const handleKeyboardShortcuts = (event) => {
|
const handleKeyboardShortcuts = (event) => {
|
||||||
// Only handle shortcuts when no input is focused
|
// Only handle shortcuts when no input is focused
|
||||||
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
|
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
|
||||||
@ -192,6 +575,18 @@ const handleKeyboardShortcuts = (event) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleBothPanels();
|
toggleBothPanels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + C: Copy selected elements
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
|
||||||
|
event.preventDefault();
|
||||||
|
copySelectedElements();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd + V: Paste elements
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
||||||
|
event.preventDefault();
|
||||||
|
pasteElements();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle node highlighting from variable usage
|
// Handle node highlighting from variable usage
|
||||||
@ -243,7 +638,7 @@ const components = [
|
|||||||
{
|
{
|
||||||
type: 'start',
|
type: 'start',
|
||||||
label: 'Start Point',
|
label: 'Start Point',
|
||||||
icon: 'play_circle_filled',
|
icon: 'play-circle-rounded',
|
||||||
iconColor: 'text-green-500',
|
iconColor: 'text-green-500',
|
||||||
data: { description: 'Process starts here' }
|
data: { description: 'Process starts here' }
|
||||||
},
|
},
|
||||||
@ -336,7 +731,7 @@ const components = [
|
|||||||
{
|
{
|
||||||
type: 'end',
|
type: 'end',
|
||||||
label: 'End Point',
|
label: 'End Point',
|
||||||
icon: 'stop_circle',
|
icon: 'stop-circle',
|
||||||
iconColor: 'text-red-500',
|
iconColor: 'text-red-500',
|
||||||
data: { description: 'Process completes here' }
|
data: { description: 'Process completes here' }
|
||||||
}
|
}
|
||||||
@ -461,8 +856,8 @@ const getNodeIcon = (nodeType) => {
|
|||||||
'business-rule': 'rule',
|
'business-rule': 'rule',
|
||||||
'notification': 'notifications',
|
'notification': 'notifications',
|
||||||
'subprocess': 'hub',
|
'subprocess': 'hub',
|
||||||
'start': 'play_circle_filled',
|
'start': 'play-circle-rounded',
|
||||||
'end': 'stop_circle',
|
'end': 'stop-circle',
|
||||||
'swimlane-horizontal': 'view-stream',
|
'swimlane-horizontal': 'view-stream',
|
||||||
'swimlane-vertical': 'view-column',
|
'swimlane-vertical': 'view-column',
|
||||||
'rectangle-shape': 'rectangle',
|
'rectangle-shape': 'rectangle',
|
||||||
@ -1113,6 +1508,9 @@ const goToManage = () => {
|
|||||||
|
|
||||||
// Add events for beforeunload
|
// Add events for beforeunload
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Load clipboard from localStorage
|
||||||
|
loadClipboardFromStorage();
|
||||||
|
|
||||||
// Check if there's a process ID in the URL query parameters
|
// Check if there's a process ID in the URL query parameters
|
||||||
const processId = route.query.id;
|
const processId = route.query.id;
|
||||||
|
|
||||||
@ -1146,6 +1544,9 @@ onMounted(() => {
|
|||||||
// Add keyboard shortcuts listener
|
// Add keyboard shortcuts listener
|
||||||
window.addEventListener('keydown', handleKeyboardShortcuts);
|
window.addEventListener('keydown', handleKeyboardShortcuts);
|
||||||
|
|
||||||
|
// Add storage event listener for cross-tab clipboard sync
|
||||||
|
window.addEventListener('storage', handleStorageEvent);
|
||||||
|
|
||||||
// Initial screen size check
|
// Initial screen size check
|
||||||
checkScreenSize();
|
checkScreenSize();
|
||||||
});
|
});
|
||||||
@ -1164,6 +1565,7 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('resize', checkScreenSize);
|
window.removeEventListener('resize', checkScreenSize);
|
||||||
window.removeEventListener('highlightNode', handleNodeHighlight);
|
window.removeEventListener('highlightNode', handleNodeHighlight);
|
||||||
window.removeEventListener('keydown', handleKeyboardShortcuts);
|
window.removeEventListener('keydown', handleKeyboardShortcuts);
|
||||||
|
window.removeEventListener('storage', handleStorageEvent);
|
||||||
|
|
||||||
// Clear highlight timeout if it exists
|
// Clear highlight timeout if it exists
|
||||||
if (highlightTimeout.value) {
|
if (highlightTimeout.value) {
|
||||||
@ -2036,6 +2438,112 @@ const canShowNodeActions = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add computed properties for copy/paste functionality
|
||||||
|
const canCopy = computed(() => {
|
||||||
|
// Check for multiple selections first
|
||||||
|
if (processFlowCanvas.value) {
|
||||||
|
try {
|
||||||
|
let selectedNodes = [];
|
||||||
|
let selectedEdges = [];
|
||||||
|
|
||||||
|
// Try the new getSelectedElements method first
|
||||||
|
if (processFlowCanvas.value.getSelectedElements) {
|
||||||
|
const selection = processFlowCanvas.value.getSelectedElements();
|
||||||
|
selectedNodes = selection.nodes || [];
|
||||||
|
selectedEdges = selection.edges || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no selection found, try direct access
|
||||||
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
||||||
|
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
|
||||||
|
selectedNodes = processFlowCanvas.value.nodes.filter(node => node.selected);
|
||||||
|
selectedEdges = processFlowCanvas.value.edges.filter(edge => edge.selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are multiple selections, any selection is copyable
|
||||||
|
if (selectedNodes.length > 0 || selectedEdges.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to check multiple selections:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to single selection check
|
||||||
|
// Can copy edges
|
||||||
|
if (selectedEdgeData.value) return true;
|
||||||
|
|
||||||
|
// Can copy any nodes (including start/end)
|
||||||
|
if (selectedNodeData.value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canPaste = computed(() => {
|
||||||
|
return clipboard.value.type && processStore.currentProcess;
|
||||||
|
});
|
||||||
|
|
||||||
|
const clipboardInfo = computed(() => {
|
||||||
|
if (!clipboard.value.type) return null;
|
||||||
|
|
||||||
|
const nodeCount = clipboard.value.nodes.length;
|
||||||
|
const edgeCount = clipboard.value.edges.length;
|
||||||
|
|
||||||
|
if (clipboard.value.type === 'mixed') {
|
||||||
|
return `${nodeCount} node${nodeCount > 1 ? 's' : ''}, ${edgeCount} edge${edgeCount > 1 ? 's' : ''}`;
|
||||||
|
} else if (clipboard.value.type === 'nodes') {
|
||||||
|
return `${nodeCount} node${nodeCount > 1 ? 's' : ''}`;
|
||||||
|
} else if (clipboard.value.type === 'edges') {
|
||||||
|
return `${edgeCount} edge${edgeCount > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add computed property to show current selection info
|
||||||
|
const currentSelectionInfo = computed(() => {
|
||||||
|
if (!processFlowCanvas.value || !processFlowCanvas.value.flowInstance) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let selectedNodes = [];
|
||||||
|
let selectedEdges = [];
|
||||||
|
|
||||||
|
// Try the new getSelectedElements method first
|
||||||
|
if (processFlowCanvas.value.getSelectedElements) {
|
||||||
|
const selection = processFlowCanvas.value.getSelectedElements();
|
||||||
|
selectedNodes = selection.nodes || [];
|
||||||
|
selectedEdges = selection.edges || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no selection found, try direct access
|
||||||
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
||||||
|
if (processFlowCanvas.value.nodes && processFlowCanvas.value.edges) {
|
||||||
|
selectedNodes = processFlowCanvas.value.nodes.filter(node => node.selected);
|
||||||
|
selectedEdges = processFlowCanvas.value.edges.filter(edge => edge.selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNodes.length === 0 && selectedEdges.length === 0) return null;
|
||||||
|
|
||||||
|
let info = '';
|
||||||
|
if (selectedNodes.length > 0) {
|
||||||
|
info += `${selectedNodes.length} node${selectedNodes.length > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
if (selectedEdges.length > 0) {
|
||||||
|
if (info) info += ', ';
|
||||||
|
info += `${selectedEdges.length} edge${selectedEdges.length > 1 ? 's' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to get current selection info:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const nodeZIndex = computed({
|
const nodeZIndex = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return selectedNodeData.value?.data?.isShape ? selectedNodeData.value.data.zIndex || 0 : 0;
|
return selectedNodeData.value?.data?.isShape ? selectedNodeData.value.data.zIndex || 0 : 0;
|
||||||
@ -2334,6 +2842,54 @@ const sendToBack = () => {
|
|||||||
</RsButton>
|
</RsButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Copy/Paste actions -->
|
||||||
|
<div class="flex items-center mr-1 md:mr-2 border-r border-gray-200 pr-1 md:pr-2">
|
||||||
|
<RsButton
|
||||||
|
@click="copySelectedElements"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
class="mr-1"
|
||||||
|
:disabled="!canCopy"
|
||||||
|
:title="`Copy selected elements (Ctrl+C)${currentSelectionInfo ? ' - ' + currentSelectionInfo : clipboardInfo ? ' - Current: ' + clipboardInfo : ''}`"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:content-copy" class="mr-0 md:mr-1" />
|
||||||
|
<span class="hidden md:inline">Copy</span>
|
||||||
|
</RsButton>
|
||||||
|
<RsButton
|
||||||
|
@click="pasteElements"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
:disabled="!canPaste"
|
||||||
|
:title="`Paste elements (Ctrl+V)${clipboardInfo ? ' - ' + clipboardInfo : ''}`"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:content-paste" class="mr-0 md:mr-1" />
|
||||||
|
<span class="hidden md:inline">Paste</span>
|
||||||
|
</RsButton>
|
||||||
|
|
||||||
|
<!-- Debug button (temporary) -->
|
||||||
|
<RsButton
|
||||||
|
@click="debugSelection"
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
class="ml-1"
|
||||||
|
title="Debug selection state"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:bug-report" class="w-4 h-4" />
|
||||||
|
</RsButton>
|
||||||
|
|
||||||
|
<!-- Selection indicator -->
|
||||||
|
<div v-if="currentSelectionInfo && !clipboardInfo" class="ml-2 px-2 py-1 bg-green-100 text-green-700 text-xs rounded-md border border-green-200 hidden sm:block" :title="`Selected: ${currentSelectionInfo}. Press Ctrl+C to copy.`">
|
||||||
|
<Icon name="material-symbols:select-all" class="w-3 h-3 mr-1" />
|
||||||
|
{{ currentSelectionInfo }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clipboard indicator -->
|
||||||
|
<div v-if="clipboardInfo" class="ml-2 px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-md border border-blue-200 hidden sm:block" :title="`Clipboard contains: ${clipboardInfo}. You can paste this in any process tab.`">
|
||||||
|
<Icon name="material-symbols:content-paste" class="w-3 h-3 mr-1" />
|
||||||
|
{{ clipboardInfo }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Secondary actions -->
|
<!-- Secondary actions -->
|
||||||
@ -2361,6 +2917,21 @@ const sendToBack = () => {
|
|||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Clipboard Actions -->
|
||||||
|
<div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide mt-2">
|
||||||
|
Clipboard
|
||||||
|
</div>
|
||||||
|
<div v-if="clipboardInfo" class="px-4 py-2 text-xs text-gray-600 bg-gray-50 border-l-2 border-blue-400">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Icon name="material-symbols:content-paste" class="w-3 h-3 mr-1" />
|
||||||
|
<span>{{ clipboardInfo }} ready to paste</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button @click="clearClipboard(); showDropdown = false" :disabled="!clipboardInfo" class="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center disabled:opacity-50">
|
||||||
|
<Icon name="mdi:clipboard-remove-outline" class="mr-2 w-4 h-4" />
|
||||||
|
<span>Clear Clipboard</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide mt-2">
|
<div class="px-3 py-1 text-xs font-medium text-gray-500 uppercase tracking-wide mt-2">
|
||||||
Settings
|
Settings
|
||||||
@ -2479,7 +3050,87 @@ const sendToBack = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<!-- Show variable manager when no node is selected -->
|
<!-- Show variable manager when no node is selected -->
|
||||||
<VariableManager v-if="!selectedNodeData" :key="`variables-${variablesUpdateKey}`" />
|
<VariableManager v-if="!selectedNodeData && !selectedEdgeData" :key="`variables-${variablesUpdateKey}`" />
|
||||||
|
|
||||||
|
<!-- Show edge properties when an edge is selected -->
|
||||||
|
<div v-else-if="selectedEdgeData" class="flex flex-col h-full">
|
||||||
|
<!-- Edge Header -->
|
||||||
|
<div class="p-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-8 h-8 rounded-lg flex items-center justify-center text-white text-sm font-medium bg-blue-500">
|
||||||
|
<Icon name="material-symbols:timeline" class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900">Connection</h3>
|
||||||
|
<p class="text-xs text-gray-500">{{ selectedEdgeData.id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<RsButton
|
||||||
|
@click="copySelectedElements"
|
||||||
|
variant="secondary-text"
|
||||||
|
size="sm"
|
||||||
|
icon
|
||||||
|
:title="`Copy Edge (Ctrl+C)${currentSelectionInfo ? ' - ' + currentSelectionInfo : clipboardInfo ? ' - Current: ' + clipboardInfo : ''}`"
|
||||||
|
class="modern-icon-btn"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:content-copy" />
|
||||||
|
</RsButton>
|
||||||
|
<RsButton
|
||||||
|
@click="deleteEdge"
|
||||||
|
variant="danger-text"
|
||||||
|
size="sm"
|
||||||
|
icon
|
||||||
|
:title="'Delete Edge'"
|
||||||
|
class="modern-icon-btn"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:delete" />
|
||||||
|
</RsButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edge Properties -->
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 mb-3 flex items-center">
|
||||||
|
<Icon name="material-symbols:edit" class="w-4 h-4 mr-2" />
|
||||||
|
Edge Properties
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<!-- Edge Label -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Label</label>
|
||||||
|
<input
|
||||||
|
v-model="selectedEdgeData.label"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Enter edge label"
|
||||||
|
@input="updateEdgeLabel(selectedEdgeData.label)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Source Node -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Source Node</label>
|
||||||
|
<div class="px-3 py-2 text-sm bg-gray-100 border border-gray-300 rounded-md">
|
||||||
|
{{ selectedEdgeData.sourceNode?.label || selectedEdgeData.source }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Target Node -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">Target Node</label>
|
||||||
|
<div class="px-3 py-2 text-sm bg-gray-100 border border-gray-300 rounded-md">
|
||||||
|
{{ selectedEdgeData.targetNode?.label || selectedEdgeData.target }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Show node properties when a node is selected -->
|
<!-- Show node properties when a node is selected -->
|
||||||
<div v-else class="flex flex-col h-full">
|
<div v-else class="flex flex-col h-full">
|
||||||
@ -2500,6 +3151,17 @@ const sendToBack = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="canShowNodeActions" class="flex items-center gap-2">
|
<div v-if="canShowNodeActions" class="flex items-center gap-2">
|
||||||
|
<RsButton
|
||||||
|
@click="copySelectedElements"
|
||||||
|
variant="secondary-text"
|
||||||
|
size="sm"
|
||||||
|
icon
|
||||||
|
:disabled="!canCopy"
|
||||||
|
:title="`Copy Node (Ctrl+C)${currentSelectionInfo ? ' - ' + currentSelectionInfo : clipboardInfo ? ' - Current: ' + clipboardInfo : ''}`"
|
||||||
|
class="modern-icon-btn"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:content-copy" />
|
||||||
|
</RsButton>
|
||||||
<RsButton
|
<RsButton
|
||||||
@click="duplicateNode"
|
@click="duplicateNode"
|
||||||
variant="secondary-text"
|
variant="secondary-text"
|
||||||
@ -2508,7 +3170,7 @@ const sendToBack = () => {
|
|||||||
:title="'Duplicate Node'"
|
:title="'Duplicate Node'"
|
||||||
class="modern-icon-btn"
|
class="modern-icon-btn"
|
||||||
>
|
>
|
||||||
<Icon name="material-symbols:content-copy" />
|
<Icon name="material-symbols:content-copy-outline" />
|
||||||
</RsButton>
|
</RsButton>
|
||||||
<RsButton
|
<RsButton
|
||||||
@click="deleteNode"
|
@click="deleteNode"
|
||||||
|
@ -177,11 +177,11 @@ export const useProcessBuilderStore = defineStore('processBuilder', {
|
|||||||
if (nodeId.includes('start-')) {
|
if (nodeId.includes('start-')) {
|
||||||
nodeType = 'start';
|
nodeType = 'start';
|
||||||
label = 'Start Point';
|
label = 'Start Point';
|
||||||
icon = 'play_circle_filled';
|
icon = 'play-circle-rounded';
|
||||||
} else if (nodeId.includes('end-')) {
|
} else if (nodeId.includes('end-')) {
|
||||||
nodeType = 'end';
|
nodeType = 'end';
|
||||||
label = 'End Point';
|
label = 'End Point';
|
||||||
icon = 'stop_circle';
|
icon = 'stop-circle';
|
||||||
} else if (nodeId.includes('form-')) {
|
} else if (nodeId.includes('form-')) {
|
||||||
nodeType = 'form';
|
nodeType = 'form';
|
||||||
label = 'Form';
|
label = 'Form';
|
||||||
|
Loading…
x
Reference in New Issue
Block a user