Add Custom HTML Component to Form Builder
- Introduced a new 'customHtml' component in FormBuilderComponents.vue, allowing users to create custom designs using HTML, CSS, and JavaScript. - Enhanced ComponentPreview.vue to render the custom HTML component with appropriate props for live preview functionality. - Updated FormBuilderFieldSettingsModal.vue to include configuration options for the custom HTML component, such as HTML, CSS, and JavaScript content, as well as settings for preview mode and script allowance. - Implemented safe and full HTML preview methods to ensure secure rendering of custom content. - Added relevant icons and descriptions for the custom HTML component in the form builder, improving user experience and clarity.
This commit is contained in:
parent
415ac5a0d1
commit
f48eee7cdb
@ -4,6 +4,7 @@ import MaskText from "~/components/formkit/TextMask.vue";
|
|||||||
import FileDropzone from "~/components/formkit/FileDropzone.vue";
|
import FileDropzone from "~/components/formkit/FileDropzone.vue";
|
||||||
import Switch from "~/components/formkit/Switch.vue";
|
import Switch from "~/components/formkit/Switch.vue";
|
||||||
import SearchSelect from "~/components/formkit/SearchSelect.vue";
|
import SearchSelect from "~/components/formkit/SearchSelect.vue";
|
||||||
|
import CustomHtml from "~/components/formkit/CustomHtml.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
otp: createInput(OneTimePassword, {
|
otp: createInput(OneTimePassword, {
|
||||||
@ -21,4 +22,7 @@ export default {
|
|||||||
searchSelect: createInput(SearchSelect, {
|
searchSelect: createInput(SearchSelect, {
|
||||||
props: ["options", "placeholder"],
|
props: ["options", "placeholder"],
|
||||||
}),
|
}),
|
||||||
|
customHtml: createInput(CustomHtml, {
|
||||||
|
props: ["htmlContent", "cssContent", "jsContent", "allowScripts", "previewMode", "showInPreview"],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
@ -627,6 +627,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom HTML Component -->
|
||||||
|
<div v-else-if="component.type === 'customHtml'" class="py-2">
|
||||||
|
<FormKit
|
||||||
|
:id="`preview-${component.id}`"
|
||||||
|
type="customHtml"
|
||||||
|
:name="component.props.name"
|
||||||
|
:label="component.props.label"
|
||||||
|
:help="component.props.help"
|
||||||
|
:htmlContent="component.props.htmlContent"
|
||||||
|
:cssContent="component.props.cssContent"
|
||||||
|
:jsContent="component.props.jsContent"
|
||||||
|
:allowScripts="component.props.allowScripts"
|
||||||
|
:previewMode="component.props.previewMode"
|
||||||
|
:showInPreview="component.props.showInPreview"
|
||||||
|
:readonly="component.props.readonly || !isPreview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Unknown Component Type Fallback -->
|
<!-- Unknown Component Type Fallback -->
|
||||||
<div v-else class="p-4 border border-dashed border-gray-300 rounded">
|
<div v-else class="p-4 border border-dashed border-gray-300 rounded">
|
||||||
<div class="text-gray-500">Unknown component type: {{ component.type }}</div>
|
<div class="text-gray-500">Unknown component type: {{ component.type }}</div>
|
||||||
|
@ -732,6 +732,78 @@ const availableComponents = [
|
|||||||
icon: 'material-symbols:horizontal-rule',
|
icon: 'material-symbols:horizontal-rule',
|
||||||
description: 'Horizontal divider line',
|
description: 'Horizontal divider line',
|
||||||
defaultProps: {}
|
defaultProps: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'customHtml',
|
||||||
|
name: 'Custom HTML',
|
||||||
|
category: 'Layout',
|
||||||
|
icon: 'material-symbols:code',
|
||||||
|
description: 'Custom HTML with CSS and JavaScript',
|
||||||
|
defaultProps: {
|
||||||
|
label: 'Custom HTML Component',
|
||||||
|
name: 'custom_html',
|
||||||
|
help: 'Create custom designs using HTML, CSS, and JavaScript',
|
||||||
|
htmlContent: `<div class="custom-component">
|
||||||
|
<h3>Custom HTML Component</h3>
|
||||||
|
<p>Edit this HTML to create your custom design.</p>
|
||||||
|
<button onclick="alert('Hello from custom HTML!')">Click me!</button>
|
||||||
|
</div>`,
|
||||||
|
cssContent: `.custom-component {
|
||||||
|
padding: 20px;
|
||||||
|
border: 2px solid #3b82f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-component h3 {
|
||||||
|
color: #1e40af;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-component button {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-component button:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}`,
|
||||||
|
jsContent: `// Custom JavaScript for this component
|
||||||
|
// You can access the component element via 'this.element'
|
||||||
|
// Available functions:
|
||||||
|
// - this.element: The HTML element of this component
|
||||||
|
// - this.getValue(): Get form values
|
||||||
|
// - this.setValue(name, value): Set form field values
|
||||||
|
|
||||||
|
console.log('Custom HTML component initialized');
|
||||||
|
|
||||||
|
// Example: Add click handler
|
||||||
|
const buttons = this.element.querySelectorAll('button');
|
||||||
|
buttons.forEach(button => {
|
||||||
|
button.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log('Custom button clicked!');
|
||||||
|
});
|
||||||
|
});`,
|
||||||
|
allowScripts: true,
|
||||||
|
previewMode: 'safe', // 'safe' or 'advanced'
|
||||||
|
width: '100%',
|
||||||
|
gridColumn: 'span 12',
|
||||||
|
showInPreview: true,
|
||||||
|
// Conditional Logic Properties
|
||||||
|
conditionalLogic: {
|
||||||
|
enabled: false,
|
||||||
|
conditions: [],
|
||||||
|
action: 'show',
|
||||||
|
operator: 'and'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1575,6 +1575,136 @@ if (name && email) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Custom HTML Configuration -->
|
||||||
|
<template v-if="component.type === 'customHtml'">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- HTML Content -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">HTML Content</h5>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="textarea"
|
||||||
|
label="HTML Code"
|
||||||
|
name="htmlContent"
|
||||||
|
v-model="configModel.htmlContent"
|
||||||
|
help="Enter your custom HTML code here"
|
||||||
|
:classes="{
|
||||||
|
outer: 'field-wrapper',
|
||||||
|
input: 'font-mono text-sm min-h-[120px]'
|
||||||
|
}"
|
||||||
|
placeholder="<div>Your custom HTML here...</div>"
|
||||||
|
rows="6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CSS Content -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">CSS Styles</h5>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="textarea"
|
||||||
|
label="CSS Code"
|
||||||
|
name="cssContent"
|
||||||
|
v-model="configModel.cssContent"
|
||||||
|
help="Enter your custom CSS styles here"
|
||||||
|
:classes="{
|
||||||
|
outer: 'field-wrapper',
|
||||||
|
input: 'font-mono text-sm min-h-[100px]'
|
||||||
|
}"
|
||||||
|
placeholder=".my-class { color: blue; }"
|
||||||
|
rows="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript Content -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">JavaScript Code</h5>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="textarea"
|
||||||
|
label="JavaScript Code"
|
||||||
|
name="jsContent"
|
||||||
|
v-model="configModel.jsContent"
|
||||||
|
help="Enter your custom JavaScript code here"
|
||||||
|
:classes="{
|
||||||
|
outer: 'field-wrapper',
|
||||||
|
input: 'font-mono text-sm min-h-[100px]'
|
||||||
|
}"
|
||||||
|
placeholder="console.log('Hello from custom HTML!');"
|
||||||
|
rows="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Component Settings</h5>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<FormKit
|
||||||
|
type="select"
|
||||||
|
label="Preview Mode"
|
||||||
|
name="previewMode"
|
||||||
|
v-model="configModel.previewMode"
|
||||||
|
help="Choose how to render the HTML content"
|
||||||
|
:options="[
|
||||||
|
{ label: 'Safe Mode (No Scripts)', value: 'safe' },
|
||||||
|
{ label: 'Advanced Mode (Full HTML)', value: 'advanced' }
|
||||||
|
]"
|
||||||
|
:classes="{ outer: 'field-wrapper' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="switch"
|
||||||
|
label="Allow Scripts"
|
||||||
|
name="allowScripts"
|
||||||
|
v-model="configModel.allowScripts"
|
||||||
|
help="Enable JavaScript execution (Advanced mode only)"
|
||||||
|
:classes="{ outer: 'field-wrapper' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormKit
|
||||||
|
type="switch"
|
||||||
|
label="Show in Preview"
|
||||||
|
name="showInPreview"
|
||||||
|
v-model="configModel.showInPreview"
|
||||||
|
help="Display this component in form preview mode"
|
||||||
|
:classes="{ outer: 'field-wrapper' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Area -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h5 class="text-sm font-medium text-gray-700 border-b pb-2">Live Preview</h5>
|
||||||
|
|
||||||
|
<div class="preview-container border border-gray-200 rounded-lg p-4 bg-gray-50 min-h-[100px]">
|
||||||
|
<div v-if="configModel.htmlContent" class="custom-html-preview" ref="previewContainer">
|
||||||
|
<!-- Safe preview - just show the HTML without scripts -->
|
||||||
|
<div v-html="getFullHtmlPreview(configModel.htmlContent, configModel.cssContent)"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-500 text-center py-8">
|
||||||
|
<Icon name="material-symbols:code" class="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
||||||
|
<p class="text-sm">No HTML content to preview</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Security Notice -->
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<Icon name="material-symbols:security" class="w-5 h-5 text-yellow-600 mr-2 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-sm text-yellow-800 mb-1">Security Notice</h4>
|
||||||
|
<p class="text-xs text-yellow-700">
|
||||||
|
Custom HTML components can contain arbitrary code. In Safe Mode, scripts and event handlers are disabled.
|
||||||
|
Use Advanced Mode only with trusted content and enable "Allow Scripts" carefully.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2027,7 +2157,8 @@ const getComponentIcon = (type) => {
|
|||||||
'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'
|
'repeating-group': 'heroicons:list-bullet',
|
||||||
|
'customHtml': 'material-symbols:code'
|
||||||
}
|
}
|
||||||
return icons[type] || 'heroicons:square-3-stack-3d'
|
return icons[type] || 'heroicons:square-3-stack-3d'
|
||||||
}
|
}
|
||||||
@ -2064,7 +2195,8 @@ const getComponentTypeName = (type) => {
|
|||||||
'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'
|
'repeating-group': 'Repeating Group',
|
||||||
|
'customHtml': 'Custom HTML'
|
||||||
}
|
}
|
||||||
return names[type] || 'Form Field'
|
return names[type] || 'Form Field'
|
||||||
}
|
}
|
||||||
@ -2101,7 +2233,8 @@ const getComponentDescription = (type) => {
|
|||||||
'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'
|
'repeating-group': 'Collect multiple entries of the same data structure',
|
||||||
|
'customHtml': 'Create custom designs using HTML, CSS, and JavaScript'
|
||||||
}
|
}
|
||||||
return descriptions[type] || 'Configure this form field'
|
return descriptions[type] || 'Configure this form field'
|
||||||
}
|
}
|
||||||
@ -2129,15 +2262,15 @@ 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', 'searchSelect', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
|
label: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'searchSelect', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group', 'customHtml'],
|
||||||
name: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'searchSelect', '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', 'searchSelect', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group', 'customHtml'],
|
||||||
placeholder: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'searchSelect', 'dynamic-list'],
|
placeholder: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'searchSelect', 'dynamic-list'],
|
||||||
help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'searchSelect', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
|
help: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'hidden', 'select', 'searchSelect', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'otp', 'dropzone', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group', 'customHtml'],
|
||||||
value: ['heading', 'paragraph', 'hidden'],
|
value: ['heading', 'paragraph', 'hidden'],
|
||||||
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'searchSelect', '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'],
|
width: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'mask', 'select', 'searchSelect', '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', 'customHtml'],
|
||||||
rows: ['textarea'],
|
rows: ['textarea'],
|
||||||
options: ['select', 'searchSelect', 'checkbox', 'radio'],
|
options: ['select', 'searchSelect', 'checkbox', 'radio'],
|
||||||
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'searchSelect', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group'],
|
conditionalLogic: ['text', 'textarea', 'number', 'email', 'password', 'url', 'tel', 'select', 'searchSelect', 'checkbox', 'radio', 'switch', 'date', 'time', 'datetime-local', 'range', 'color', 'file', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group', 'customHtml'],
|
||||||
readonly: ['text', 'number', 'email', 'password', 'textarea', 'mask', 'url', 'tel', 'select', 'searchSelect', 'checkbox', 'radio', 'switch']
|
readonly: ['text', 'number', 'email', 'password', 'textarea', 'mask', 'url', 'tel', 'select', 'searchSelect', 'checkbox', 'radio', 'switch']
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2147,7 +2280,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', 'repeating-group']
|
const specificTypes = ['mask', 'otp', 'dropzone', 'range', 'button', 'form-section', 'dynamic-list', 'repeating-table', 'repeating-group', 'customHtml']
|
||||||
return specificTypes.includes(props.component.type)
|
return specificTypes.includes(props.component.type)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -2190,6 +2323,48 @@ const isTextBasedField = computed(() => {
|
|||||||
return ['text', 'textarea', 'email', 'password', 'url', 'tel'].includes(props.component?.type)
|
return ['text', 'textarea', 'email', 'password', 'url', 'tel'].includes(props.component?.type)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Safe HTML preview for custom HTML component
|
||||||
|
const getSafeHtmlPreview = (htmlContent) => {
|
||||||
|
if (!htmlContent) return ''
|
||||||
|
|
||||||
|
// Remove script tags and event handlers for safe preview
|
||||||
|
return htmlContent
|
||||||
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||||
|
.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, '')
|
||||||
|
.replace(/javascript:/gi, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full HTML preview with CSS included
|
||||||
|
const getFullHtmlPreview = (htmlContent, cssContent) => {
|
||||||
|
if (!htmlContent) return ''
|
||||||
|
|
||||||
|
const safeHtml = getSafeHtmlPreview(htmlContent)
|
||||||
|
|
||||||
|
if (cssContent) {
|
||||||
|
// Create a unique ID for scoping
|
||||||
|
const previewId = `preview-${Date.now()}`
|
||||||
|
|
||||||
|
// Scope the CSS to the preview container
|
||||||
|
const scopedCss = cssContent.replace(
|
||||||
|
/([^@{}]+)\s*{/g,
|
||||||
|
`#${previewId} $1 {`
|
||||||
|
)
|
||||||
|
|
||||||
|
return `
|
||||||
|
<style>
|
||||||
|
#${previewId} {
|
||||||
|
/* Preview container styles */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
${scopedCss}
|
||||||
|
</style>
|
||||||
|
<div id="${previewId}">${safeHtml}</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeHtml
|
||||||
|
}
|
||||||
|
|
||||||
// Published processes for button linking
|
// Published processes for button linking
|
||||||
const publishedProcesses = ref([])
|
const publishedProcesses = ref([])
|
||||||
|
|
||||||
|
231
components/formkit/CustomHtml.vue
Normal file
231
components/formkit/CustomHtml.vue
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
context: Object,
|
||||||
|
});
|
||||||
|
|
||||||
|
const componentRef = ref(null);
|
||||||
|
const uniqueId = ref(`custom-html-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
|
||||||
|
|
||||||
|
// Get component properties with defaults
|
||||||
|
const htmlContent = computed(() => props.context.htmlContent || '<p>No HTML content provided</p>');
|
||||||
|
const cssContent = computed(() => props.context.cssContent || '');
|
||||||
|
const jsContent = computed(() => props.context.jsContent || '');
|
||||||
|
const allowScripts = computed(() => props.context.allowScripts !== false);
|
||||||
|
const previewMode = computed(() => props.context.previewMode || 'safe');
|
||||||
|
const showInPreview = computed(() => props.context.showInPreview !== false);
|
||||||
|
|
||||||
|
// Create scoped CSS with unique ID
|
||||||
|
const scopedCss = computed(() => {
|
||||||
|
if (!cssContent.value) return '';
|
||||||
|
|
||||||
|
// Add scope to CSS rules
|
||||||
|
const scopedRules = cssContent.value.replace(
|
||||||
|
/([^@{}]+)\s*{/g,
|
||||||
|
`#${uniqueId.value} $1 {`
|
||||||
|
);
|
||||||
|
|
||||||
|
return `<style scoped>
|
||||||
|
#${uniqueId.value} {
|
||||||
|
/* Component container styles */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
${scopedRules}
|
||||||
|
</style>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Safe HTML content (no scripts)
|
||||||
|
const safeHtmlContent = computed(() => {
|
||||||
|
if (previewMode.value === 'safe') {
|
||||||
|
// Remove script tags and event handlers for safe mode
|
||||||
|
return htmlContent.value
|
||||||
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||||
|
.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, '')
|
||||||
|
.replace(/javascript:/gi, '');
|
||||||
|
}
|
||||||
|
return htmlContent.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute custom JavaScript in a controlled environment
|
||||||
|
const executeCustomScript = () => {
|
||||||
|
if (!allowScripts.value || !jsContent.value || previewMode.value === 'safe') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a safe execution context
|
||||||
|
const scriptContext = {
|
||||||
|
element: componentRef.value,
|
||||||
|
console: console,
|
||||||
|
|
||||||
|
// Helper functions for form interaction
|
||||||
|
getValue: (fieldName) => {
|
||||||
|
// Access parent form context if available
|
||||||
|
if (props.context.node && props.context.node.parent) {
|
||||||
|
const formData = props.context.node.parent.value;
|
||||||
|
return formData ? formData[fieldName] : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
setValue: (fieldName, value) => {
|
||||||
|
// Set form field value if parent context available
|
||||||
|
if (props.context.node && props.context.node.parent) {
|
||||||
|
props.context.node.parent.input({ [fieldName]: value }, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Safe DOM methods
|
||||||
|
querySelector: (selector) => componentRef.value?.querySelector(selector),
|
||||||
|
querySelectorAll: (selector) => componentRef.value?.querySelectorAll(selector),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create function with custom context
|
||||||
|
const scriptFunction = new Function('console', 'element', 'getValue', 'setValue', 'querySelector', 'querySelectorAll', jsContent.value);
|
||||||
|
|
||||||
|
// Execute with controlled context
|
||||||
|
scriptFunction.call(
|
||||||
|
scriptContext,
|
||||||
|
scriptContext.console,
|
||||||
|
scriptContext.element,
|
||||||
|
scriptContext.getValue,
|
||||||
|
scriptContext.setValue,
|
||||||
|
scriptContext.querySelector,
|
||||||
|
scriptContext.querySelectorAll
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Custom HTML script execution error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for changes and re-execute script
|
||||||
|
watch([htmlContent, cssContent, jsContent], () => {
|
||||||
|
nextTick(() => {
|
||||||
|
executeCustomScript();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute script after component is mounted
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
executeCustomScript();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up any event listeners when component is unmounted
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (componentRef.value) {
|
||||||
|
// Remove any dynamically added event listeners
|
||||||
|
const buttons = componentRef.value.querySelectorAll('button, a, [onclick]');
|
||||||
|
buttons.forEach(el => {
|
||||||
|
el.replaceWith(el.cloneNode(true)); // Remove all event listeners
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't display anything if showInPreview is false and we're in readonly/preview mode
|
||||||
|
const shouldShow = computed(() => {
|
||||||
|
if (!props.context.readonly) return true; // Always show in edit mode
|
||||||
|
return showInPreview.value; // Only show in preview if enabled
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="shouldShow" class="custom-html-wrapper">
|
||||||
|
<!-- Component Label -->
|
||||||
|
<div v-if="context.label && context.label !== 'Custom HTML Component'" class="mb-2">
|
||||||
|
<label class="text-sm font-medium text-gray-700">{{ context.label }}</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Text -->
|
||||||
|
<div v-if="context.help" class="mb-2 text-xs text-gray-600">
|
||||||
|
{{ context.help }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Safe Mode Warning (only show in builder mode, not preview) -->
|
||||||
|
<div v-if="!context.readonly && previewMode === 'safe' && (jsContent || htmlContent.includes('on'))"
|
||||||
|
class="mb-2 p-2 bg-yellow-50 border border-yellow-200 rounded text-xs text-yellow-800">
|
||||||
|
<Icon name="material-symbols:warning-outline" class="w-4 h-4 inline mr-1" />
|
||||||
|
Safe Mode: Scripts and event handlers are disabled for security.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom HTML Content Container -->
|
||||||
|
<div
|
||||||
|
:id="uniqueId"
|
||||||
|
ref="componentRef"
|
||||||
|
class="custom-html-content"
|
||||||
|
:class="{
|
||||||
|
'readonly-mode': context.readonly,
|
||||||
|
'safe-mode': previewMode === 'safe',
|
||||||
|
'advanced-mode': previewMode === 'advanced'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Inject scoped CSS -->
|
||||||
|
<div v-if="cssContent" v-html="scopedCss"></div>
|
||||||
|
|
||||||
|
<!-- Render HTML content -->
|
||||||
|
<div v-html="safeHtmlContent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Development Info (only visible in builder mode, not preview) -->
|
||||||
|
<div v-if="false && !context.readonly && (htmlContent || cssContent || jsContent)"
|
||||||
|
class="mt-2 p-2 bg-gray-50 border border-gray-200 rounded text-xs text-gray-600">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div v-if="htmlContent">
|
||||||
|
<Icon name="material-symbols:html" class="w-3 h-3 inline mr-1" />
|
||||||
|
HTML: {{ htmlContent.length }} chars
|
||||||
|
</div>
|
||||||
|
<div v-if="cssContent">
|
||||||
|
<Icon name="material-symbols:css" class="w-3 h-3 inline mr-1" />
|
||||||
|
CSS: {{ cssContent.length }} chars
|
||||||
|
</div>
|
||||||
|
<div v-if="jsContent">
|
||||||
|
<Icon name="material-symbols:javascript" class="w-3 h-3 inline mr-1" />
|
||||||
|
JS: {{ jsContent.length }} chars
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.custom-html-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-html-content {
|
||||||
|
min-height: 40px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-html-content.readonly-mode {
|
||||||
|
pointer-events: auto; /* Allow interactions in readonly mode for custom HTML */
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-html-content.safe-mode {
|
||||||
|
/* Additional safe mode styling if needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-html-content.advanced-mode {
|
||||||
|
/* Additional advanced mode styling if needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent layout issues with custom HTML */
|
||||||
|
.custom-html-content :deep(*) {
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure images are responsive */
|
||||||
|
.custom-html-content :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent custom HTML from breaking out of container */
|
||||||
|
.custom-html-content {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
Loading…
x
Reference in New Issue
Block a user