@myissue/vue-website-page-builder
Version:
Vue 3 page builder component with drag & drop functionality.
1,535 lines (1,282 loc) • 51.2 kB
text/typescript
// Type definitions
import type {
ComponentObject,
ImageObject,
TimerHandle,
MutationObserver as MutationObserverType,
TailwindColors,
TailwindOpacities,
TailwindFontSizes,
TailwindFontStyles,
TailwindPaddingAndMargin,
TailwindBorderRadius,
TailwindBorderStyleWidthColor,
PageBuilderConfig,
} from '../types'
import type { usePageBuilderStateStore } from '../stores/page-builder-state'
import tailwindColors from '../utils/builder/tailwaind-colors'
import tailwindOpacities from '../utils/builder/tailwind-opacities'
import tailwindFontSizes from '../utils/builder/tailwind-font-sizes'
import tailwindFontStyles from '../utils/builder/tailwind-font-styles'
import tailwindPaddingAndMargin from '../utils/builder/tailwind-padding-margin'
import tailwindBorderRadius from '../utils/builder/tailwind-border-radius'
import tailwindBorderStyleWidthPlusColor from '../utils/builder/tailwind-border-style-width-color'
import { computed, ref, nextTick } from 'vue'
import type { ComputedRef } from 'vue'
import { v4 as uuidv4 } from 'uuid'
import { delay } from './delay'
class PageBuilderClass {
// Class properties with types
private nextTick: Promise<void>
private containsPagebuilder: Element | null
private pageBuilderStateStore: ReturnType<typeof usePageBuilderStateStore>
private getTextAreaVueModel: ComputedRef<string | null>
private getLocalStorageItemName: ComputedRef<string | null>
private getCurrentImage: ComputedRef<ImageObject>
private getHyberlinkEnable: ComputedRef<boolean>
private getComponents: ComputedRef<ComponentObject[] | null>
private getComponent: ComputedRef<ComponentObject | null>
private getElement: ComputedRef<HTMLElement | null>
private getNextSibling: ComputedRef<HTMLElement | null>
private getParentElement: ComputedRef<HTMLElement | null>
private getRestoredElement: ComputedRef<string | null>
private getComponentArrayAddMethod: ComputedRef<string | null>
private NoneListernesTags: string[]
private delay: (ms?: number) => Promise<void>
private observer?: MutationObserverType
constructor(pageBuilderStateStore: ReturnType<typeof usePageBuilderStateStore>) {
this.nextTick = nextTick()
this.containsPagebuilder = document.querySelector('#contains-pagebuilder')
this.pageBuilderStateStore = pageBuilderStateStore
this.getTextAreaVueModel = computed(() => this.pageBuilderStateStore.getTextAreaVueModel)
this.getLocalStorageItemName = computed(
() => this.pageBuilderStateStore.getLocalStorageItemName,
)
this.getCurrentImage = computed(() => this.pageBuilderStateStore.getCurrentImage)
this.getHyberlinkEnable = computed(() => this.pageBuilderStateStore.getHyberlinkEnable)
this.getComponents = computed(() => this.pageBuilderStateStore.getComponents)
this.getComponent = computed(() => this.pageBuilderStateStore.getComponent)
this.getElement = computed(() => this.pageBuilderStateStore.getElement)
this.getNextSibling = computed(() => this.pageBuilderStateStore.getNextSibling)
this.getParentElement = computed(() => this.pageBuilderStateStore.getParentElement)
this.getRestoredElement = computed(() => this.pageBuilderStateStore.getRestoredElement)
this.getComponentArrayAddMethod = computed(
() => this.pageBuilderStateStore.getComponentArrayAddMethod,
)
this.NoneListernesTags = [
'P',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'IFRAME',
'UL',
'OL',
'LI',
'EM',
'STRONG',
'B',
'A',
'SPAN',
'BLOCKQUOTE',
'BR',
]
this.delay = delay
}
// Load existing content from HTML when in update mode
async clearHtmlSelection(): Promise<void> {
this.pageBuilderStateStore.setComponent(null)
this.pageBuilderStateStore.setElement(null)
await this.#removeHoveredAndSelected()
}
// Load existing content from HTML when in update mode
setConfigPageBuilder(data: PageBuilderConfig): void {
this.pageBuilderStateStore.setConfigPageBuilder(data)
}
#applyElementClassChanges(
selectedCSS: string | undefined,
CSSArray: string[],
mutationName: string,
): string | undefined {
const currentHTMLElement = this.getElement.value
if (!currentHTMLElement) return
const currentCSS = CSSArray.find((CSS) => {
return currentHTMLElement.classList.contains(CSS)
})
// set to 'none' if undefined
let elementClass = currentCSS || 'none'
// If selectedCSS is undefined, just set the current state and return
if (selectedCSS === undefined) {
if (typeof mutationName === 'string' && mutationName.length > 2) {
;(this.pageBuilderStateStore as any)[mutationName](elementClass)
}
return currentCSS
}
// selectedCSS examples: bg-zinc-200, px-10, rounded-full etc.
if (typeof selectedCSS === 'string' && selectedCSS !== 'none') {
if (elementClass && currentHTMLElement.classList.contains(elementClass)) {
currentHTMLElement.classList.remove(elementClass)
}
currentHTMLElement.classList.add(selectedCSS)
elementClass = selectedCSS
} else if (typeof selectedCSS === 'string' && selectedCSS === 'none' && elementClass) {
currentHTMLElement.classList.remove(elementClass)
elementClass = selectedCSS
}
// Only call store mutations after all DOM manipulation is complete
if (typeof mutationName === 'string' && mutationName.length > 2) {
;(this.pageBuilderStateStore as any)[mutationName](elementClass)
this.pageBuilderStateStore.setElement(currentHTMLElement)
}
return currentCSS
}
#applyHelperCSSToElements(element: HTMLElement): void {
this.#wrapElementInDivIfExcluded(element)
if (element.tagName === 'IMG') {
element.classList.add('smooth-transition')
}
// Add padding to DIV
if (element.tagName === 'DIV') {
if (element.classList.length === 0) {
// element.classList.add("p-2");
}
}
}
#wrapElementInDivIfExcluded(element: HTMLElement): void {
if (!element) return
if (
this.NoneListernesTags.includes(element.tagName) &&
((element.previousElementSibling && element.previousElementSibling.tagName === 'IMG') ||
(element.nextElementSibling && element.nextElementSibling.tagName === 'IMG'))
) {
const divWrapper = document.createElement('div')
element.parentNode?.insertBefore(divWrapper, element)
divWrapper.appendChild(element)
}
}
#handleElementClick = async (e: Event, element: HTMLElement): Promise<void> => {
e.preventDefault()
e.stopPropagation()
const pagebuilder = document.querySelector('#pagebuilder')
if (!pagebuilder) return
this.pageBuilderStateStore.setMenuRight(true)
const selectedElement = pagebuilder.querySelector('[selected]')
if (selectedElement) {
selectedElement.removeAttribute('selected')
}
element.removeAttribute('hovered')
element.setAttribute('selected', '')
this.pageBuilderStateStore.setElement(element)
}
#handleMouseOver = (e: Event, element: HTMLElement): void => {
e.preventDefault()
e.stopPropagation()
const pagebuilder = document.querySelector('#pagebuilder')
if (!pagebuilder) return
const hoveredElement = pagebuilder.querySelector('[hovered]')
if (hoveredElement) {
hoveredElement.removeAttribute('hovered')
}
if (!element.hasAttribute('selected')) {
element.setAttribute('hovered', '')
}
}
#handleMouseLeave = (e: Event): void => {
e.preventDefault()
e.stopPropagation()
const pagebuilder = document.querySelector('#pagebuilder')
if (!pagebuilder) return
const hoveredElement = pagebuilder.querySelector('[hovered]')
if (hoveredElement) {
hoveredElement.removeAttribute('hovered')
}
}
isEditableElement(el: Element | null): boolean {
if (!el) return false
return !this.NoneListernesTags.includes(el.tagName)
}
/**
* The function is used to
* attach event listeners to each element within a 'section'
*/
setEventListenersForElements = async () => {
const elementsWithListeners = new WeakSet<Element>()
const pagebuilder = document.querySelector('#pagebuilder')
if (!pagebuilder) return
// Wait for any pending DOM updates
await nextTick()
await new Promise((resolve) => requestAnimationFrame(resolve))
pagebuilder.querySelectorAll('section *').forEach((element) => {
// exclude NoneListernesTags && additional Tags for not listening
if (this.isEditableElement(element)) {
if (elementsWithListeners && !elementsWithListeners.has(element)) {
elementsWithListeners.add(element)
// Type assertion to HTMLElement since we know these are DOM elements
const htmlElement = element as HTMLElement
// Attach event listeners directly to individual elements
htmlElement.addEventListener('click', (e) => this.#handleElementClick(e, htmlElement))
htmlElement.addEventListener('mouseover', (e) => this.#handleMouseOver(e, htmlElement))
htmlElement.addEventListener('mouseleave', (e) => this.#handleMouseLeave(e))
}
}
// end for each iterating over elements
})
}
handleAutoSave = async () => {
const passedConfig = this.pageBuilderStateStore.getConfigPageBuilder
// Check if config is set
if (passedConfig && passedConfig.userSettings) {
//
// Enabled auto save
if (
typeof passedConfig.userSettings.autoSave === 'boolean' &&
passedConfig.userSettings.autoSave !== false
) {
if (this.pageBuilderStateStore.getIsSaving) return
try {
this.pageBuilderStateStore.setIsSaving(true)
await this.delay(1000)
await this.saveComponentsLocalStorage()
} catch (err) {
console.error('Error trying auto save.', err)
} finally {
this.pageBuilderStateStore.setIsSaving(false)
}
}
}
if (passedConfig && !passedConfig.userSettings) {
try {
this.pageBuilderStateStore.setIsSaving(true)
await this.delay(200)
await this.saveComponentsLocalStorage()
} catch (err) {
console.error('Error trying saving.', err)
} finally {
this.pageBuilderStateStore.setIsSaving(false)
}
}
}
handleManualSave = async () => {
const passedConfig = this.pageBuilderStateStore.getConfigPageBuilder
// Check if config is set
if (passedConfig && passedConfig.userSettings) {
//
//
// Enabled auto save
if (
(typeof passedConfig.userSettings.autoSave === 'boolean' &&
!passedConfig.userSettings.autoSave) ||
(typeof passedConfig.userSettings.autoSave === 'boolean' &&
passedConfig.userSettings.autoSave)
) {
this.pageBuilderStateStore.setIsSaving(true)
await this.delay(200)
await this.saveComponentsLocalStorage()
this.pageBuilderStateStore.setIsSaving(false)
}
}
if (passedConfig && !passedConfig.userSettings) {
this.pageBuilderStateStore.setIsSaving(true)
await this.delay(200)
await this.saveComponentsLocalStorage()
this.pageBuilderStateStore.setIsSaving(false)
}
}
cloneCompObjForDOMInsertion(componentObject: ComponentObject): ComponentObject {
// Deep clone clone component
const clonedComponent = { ...componentObject }
// scoll to top or bottom # end
if (this.containsPagebuilder) {
if (
this.getComponentArrayAddMethod.value === 'unshift' ||
this.getComponentArrayAddMethod.value === 'push'
) {
// push to top
if (this.getComponentArrayAddMethod.value === 'unshift') {
this.containsPagebuilder.scrollTo({
top: 0,
behavior: 'smooth',
})
}
// push to bottom
if (this.getComponentArrayAddMethod.value === 'push') {
const maxHeight = this.containsPagebuilder.scrollHeight
this.containsPagebuilder.scrollTo({
top: maxHeight,
behavior: 'smooth',
})
}
}
}
// Create a DOMParser instance
const parser = new DOMParser()
// Parse the HTML content of the clonedComponent using the DOMParser
const doc = parser.parseFromString(clonedComponent.html_code || '', 'text/html')
// Selects all elements within the HTML document, including elements like:
const elements = doc.querySelectorAll('*')
elements.forEach((element) => {
this.#applyHelperCSSToElements(element as HTMLElement)
})
// Add the component id to the section element
const section = doc.querySelector('section')
if (section) {
// Generate a unique ID using uuidv4() and assign it to the section
section.dataset.componentid = uuidv4()
// Find all images within elements with "flex" or "grid" classes inside the section
const images = section.querySelectorAll('img')
// Add a unique ID as a data attribute to each image element
images.forEach((image) => {
image.setAttribute('data-image', uuidv4())
})
// Update the clonedComponent id with the newly generated unique ID
clonedComponent.id = section.dataset.componentid
// Update the HTML content of the clonedComponent with the modified HTML
clonedComponent.html_code = section.outerHTML
}
// return to the cloned element to be dropped
return clonedComponent
}
async #removeHoveredAndSelected() {
await new Promise((resolve) => requestAnimationFrame(resolve))
const pagebuilder = document.querySelector('#pagebuilder')
if (!pagebuilder) return
const hoveredElement = pagebuilder.querySelector('[hovered]')
if (hoveredElement) {
hoveredElement.removeAttribute('hovered')
}
const selectedElement = pagebuilder.querySelector('[selected]')
if (selectedElement) {
selectedElement.removeAttribute('selected')
}
}
async currentClasses() {
await new Promise((resolve) => requestAnimationFrame(resolve))
// convert classList to array
const classListArray = Array.from(this.getElement.value?.classList || [])
// commit array to store
this.pageBuilderStateStore.setCurrentClasses(classListArray)
}
handleAddClasses(userSelectedClass: string): void {
if (
typeof userSelectedClass === 'string' &&
userSelectedClass !== '' &&
!userSelectedClass.includes(' ') &&
// Check if class already exists
!this.getElement.value?.classList.contains(userSelectedClass)
) {
// Remove any leading/trailing spaces
const cleanedClass = userSelectedClass.trim()
this.getElement.value?.classList.add(cleanedClass)
this.pageBuilderStateStore.setElement(this.getElement.value)
this.pageBuilderStateStore.setClass(userSelectedClass)
}
}
handleRemoveClasses(userSelectedClass: string): void {
// remove selected class from element
if (this.getElement.value?.classList.contains(userSelectedClass)) {
this.getElement.value?.classList.remove(userSelectedClass)
this.pageBuilderStateStore.setElement(this.getElement.value)
this.pageBuilderStateStore.removeClass(userSelectedClass)
}
}
handleDeleteElement() {
// Get the element to be deleted
const element = this.getElement.value
if (!element) return
if (!element?.parentNode) {
this.pageBuilderStateStore.setComponent(null)
this.pageBuilderStateStore.setElement(null)
return
}
// Store the parent of the deleted element
// if parent element tag is section remove the hole component
if (element.parentElement?.tagName !== 'SECTION') {
this.pageBuilderStateStore.setParentElement(element.parentNode as HTMLElement)
// Store the outerHTML of the deleted element
this.pageBuilderStateStore.setRestoredElement(element.outerHTML)
// Store the next sibling of the deleted element
this.pageBuilderStateStore.setNextSibling(element.nextSibling as HTMLElement | null)
}
// if parent element tag is section remove the hole component
if (element.parentElement?.tagName === 'SECTION') {
this.deleteComponent()
}
// Remove the element from the DOM
element.remove()
this.pageBuilderStateStore.setComponent(null)
this.pageBuilderStateStore.setElement(null)
}
async handleRestoreElement() {
// Get the stored deleted element and its parent
if (this.getRestoredElement.value && this.getParentElement.value) {
// Create a new element from the stored outerHTML
const newElement = document.createElement('div')
// Fixed type conversion issue
newElement.innerHTML = this.getRestoredElement.value
// Append the restored element to its parent
// Insert the restored element before its next sibling in its parent
if (newElement.firstChild && this.getParentElement.value) {
// insertBefore can accept null as second parameter (will append to end)
this.getParentElement.value.insertBefore(newElement.firstChild, this.getNextSibling.value)
}
}
// Clear
this.pageBuilderStateStore.setParentElement(null)
this.pageBuilderStateStore.setRestoredElement(null)
this.pageBuilderStateStore.setNextSibling(null)
this.pageBuilderStateStore.setComponent(null)
this.pageBuilderStateStore.setElement(null)
await this.setEventListenersForElements()
}
handleFontWeight(userSelectedFontWeight?: string): void {
this.#applyElementClassChanges(
userSelectedFontWeight,
tailwindFontStyles.fontWeight,
'setFontWeight',
)
}
handleFontFamily(userSelectedFontFamily?: string): void {
this.#applyElementClassChanges(
userSelectedFontFamily,
tailwindFontStyles.fontFamily,
'setFontFamily',
)
}
handleFontStyle(userSelectedFontStyle?: string): void {
this.#applyElementClassChanges(
userSelectedFontStyle,
tailwindFontStyles.fontStyle,
'setFontStyle',
)
}
handleVerticalPadding(userSelectedVerticalPadding?: string): void {
this.#applyElementClassChanges(
userSelectedVerticalPadding,
tailwindPaddingAndMargin.verticalPadding,
'setFontVerticalPadding',
)
}
handleHorizontalPadding(userSelectedHorizontalPadding?: string): void {
this.#applyElementClassChanges(
userSelectedHorizontalPadding,
tailwindPaddingAndMargin.horizontalPadding,
'setFontHorizontalPadding',
)
}
handleVerticalMargin(userSelectedVerticalMargin?: string): void {
this.#applyElementClassChanges(
userSelectedVerticalMargin,
tailwindPaddingAndMargin.verticalMargin,
'setFontVerticalMargin',
)
}
handleHorizontalMargin(userSelectedHorizontalMargin?: string): void {
this.#applyElementClassChanges(
userSelectedHorizontalMargin,
tailwindPaddingAndMargin.horizontalMargin,
'setFontHorizontalMargin',
)
}
// border color, style & width / start
handleBorderStyle(borderStyle?: string): void {
this.#applyElementClassChanges(
borderStyle,
tailwindBorderStyleWidthPlusColor.borderStyle,
'setBorderStyle',
)
}
handleBorderWidth(borderWidth?: string): void {
this.#applyElementClassChanges(
borderWidth,
tailwindBorderStyleWidthPlusColor.borderWidth,
'setBorderWidth',
)
}
handleBorderColor(borderColor?: string): void {
this.#applyElementClassChanges(
borderColor,
tailwindBorderStyleWidthPlusColor.borderColor,
'setBorderColor',
)
}
// border color, style & width / end
handleBackgroundColor(color?: string): void {
this.#applyElementClassChanges(
color,
tailwindColors.backgroundColorVariables,
'setBackgroundColor',
)
}
handleTextColor(color?: string): void {
this.#applyElementClassChanges(color, tailwindColors.textColorVariables, 'setTextColor')
}
// border radius / start
handleBorderRadiusGlobal(borderRadiusGlobal?: string): void {
this.#applyElementClassChanges(
borderRadiusGlobal,
tailwindBorderRadius.roundedGlobal,
'setBorderRadiusGlobal',
)
}
handleBorderRadiusTopLeft(borderRadiusTopLeft?: string): void {
this.#applyElementClassChanges(
borderRadiusTopLeft,
tailwindBorderRadius.roundedTopLeft,
'setBorderRadiusTopLeft',
)
}
handleBorderRadiusTopRight(borderRadiusTopRight?: string): void {
this.#applyElementClassChanges(
borderRadiusTopRight,
tailwindBorderRadius.roundedTopRight,
'setBorderRadiusTopRight',
)
}
handleBorderRadiusBottomleft(borderRadiusBottomleft?: string): void {
this.#applyElementClassChanges(
borderRadiusBottomleft,
tailwindBorderRadius.roundedBottomLeft,
'setBorderRadiusBottomleft',
)
}
handleBorderRadiusBottomRight(borderRadiusBottomRight?: string): void {
this.#applyElementClassChanges(
borderRadiusBottomRight,
tailwindBorderRadius.roundedBottomRight,
'setBorderRadiusBottomRight',
)
}
// border radius / end
handleFontSize(userSelectedFontSize?: string): void {
if (!userSelectedFontSize) return
if (!this.getElement.value) return
let fontBase = tailwindFontSizes.fontBase.find((size: string) => {
return this.getElement.value?.classList.contains(size)
})
if (fontBase === undefined) {
fontBase = 'base:none'
}
let fontDesktop = tailwindFontSizes.fontDesktop.find((size: string) => {
return this.getElement.value?.classList.contains(size)
})
if (fontDesktop === undefined) {
fontDesktop = 'lg:none'
}
let fontTablet = tailwindFontSizes.fontTablet.find((size: string) => {
return this.getElement.value?.classList.contains(size)
})
if (fontTablet === undefined) {
fontTablet = 'md:none'
}
let fontMobile = tailwindFontSizes.fontMobile.find((size: string) => {
return this.getElement.value?.classList.contains(size)
})
if (fontMobile === undefined) {
fontMobile = 'sm:none'
}
// set fonts
this.pageBuilderStateStore.setFontBase(fontBase)
this.pageBuilderStateStore.setFontDesktop(fontDesktop)
this.pageBuilderStateStore.setFontTablet(fontTablet)
this.pageBuilderStateStore.setFontMobile(fontMobile)
const getFontBase = computed(() => {
return this.pageBuilderStateStore.getFontBase
})
const getFontDesktop = computed(() => {
return this.pageBuilderStateStore.getFontDesktop
})
const getFontTablet = computed(() => {
return this.pageBuilderStateStore.getFontTablet
})
const getFontMobile = computed(() => {
return this.pageBuilderStateStore.getFontMobile
})
if (userSelectedFontSize !== undefined && this.getElement.value) {
if (
!userSelectedFontSize.includes('sm:') &&
!userSelectedFontSize.includes('md:') &&
!userSelectedFontSize.includes('lg:')
) {
if (getFontBase.value) {
this.getElement.value.classList.remove(getFontBase.value)
}
if (!userSelectedFontSize.includes('base:none')) {
this.getElement.value.classList.add(userSelectedFontSize)
}
this.pageBuilderStateStore.setFontBase(userSelectedFontSize)
}
if (userSelectedFontSize.includes('lg:')) {
if (getFontDesktop.value) {
this.getElement.value.classList.remove(getFontDesktop.value)
}
if (!userSelectedFontSize.includes('lg:none')) {
this.getElement.value.classList.add(userSelectedFontSize)
}
this.pageBuilderStateStore.setFontDesktop(userSelectedFontSize)
}
if (userSelectedFontSize.includes('md:')) {
if (getFontTablet.value) {
this.getElement.value.classList.remove(getFontTablet.value)
}
if (!userSelectedFontSize.includes('md:none')) {
this.getElement.value.classList.add(userSelectedFontSize)
}
this.pageBuilderStateStore.setFontTablet(userSelectedFontSize)
}
if (userSelectedFontSize.includes('sm:')) {
if (getFontMobile.value) {
this.getElement.value.classList.remove(getFontMobile.value)
}
if (!userSelectedFontSize.includes('sm:none')) {
this.getElement.value.classList.add(userSelectedFontSize)
}
this.pageBuilderStateStore.setFontMobile(userSelectedFontSize)
}
this.pageBuilderStateStore.setElement(this.getElement.value)
}
}
handleBackgroundOpacity(opacity?: string): void {
this.#applyElementClassChanges(
opacity,
tailwindOpacities.backgroundOpacities,
'setBackgroundOpacity',
)
}
handleOpacity(opacity?: string): void {
this.#applyElementClassChanges(opacity, tailwindOpacities.opacities, 'setOpacity')
}
deleteAllComponents() {
this.pageBuilderStateStore.setComponents([])
}
deleteComponent() {
if (!this.getComponents.value || !this.getComponent.value) return
// Find the index of the component to delete
const indexToDelete = this.getComponents.value.findIndex(
(component) => component.id === this.getComponent.value?.id,
)
if (indexToDelete === -1) {
// Component not found in the array, handle this case as needed.
return
}
// Remove the component from the array
this.getComponents.value.splice(indexToDelete, 1)
this.pageBuilderStateStore.setComponents(this.getComponents.value)
this.pageBuilderStateStore.setComponent(null)
this.pageBuilderStateStore.setElement(null)
}
// move component
// runs when html components are rearranged
moveComponent(direction: number): void {
if (!this.getComponents.value || !this.getComponent.value) return
if (this.getComponents.value.length <= 1) return
// Get the component you want to move (replace this with your actual logic)
const componentToMove = this.getComponent.value
// Determine the new index where you want to move the component
const currentIndex = this.getComponents.value.findIndex(
(component) => component.id === componentToMove.id,
)
if (currentIndex === -1) {
// Component not found in the array, handle this case as needed.
return
}
const newIndex = currentIndex + direction
// Ensure the newIndex is within bounds
if (newIndex < 0 || newIndex >= this.getComponents.value.length) {
return
}
// Move the component to the new position
this.getComponents.value.splice(currentIndex, 1)
this.getComponents.value.splice(newIndex, 0, componentToMove)
}
ensureTextAreaHasContent = () => {
if (!this.getElement.value) return
// text content
if (typeof this.getElement.value.innerHTML !== 'string') {
return
}
const element = this.getElement.value
const elementTag = element.tagName
if (
['DIV'].includes(elementTag) &&
element.tagName.toLowerCase() !== 'img' &&
element.textContent &&
Number(element.textContent.length) === 0
) {
element.classList.add('h-6')
element.classList.add('bg-red-50')
} else {
element.classList.remove('h-6')
element.classList.remove('bg-red-50')
}
}
//
handleTextInput = async (textContentVueModel: string): Promise<void> => {
// // text content
if (typeof this.getElement.value?.innerHTML !== 'string') {
return
}
if (typeof this.getElement.value.innerHTML === 'string') {
await this.nextTick
// Update text content
this.getElement.value.textContent = textContentVueModel
this.pageBuilderStateStore.setTextAreaVueModel(this.getElement.value.innerHTML)
this.getElement.value.innerHTML = textContentVueModel
}
this.ensureTextAreaHasContent()
}
//
//
ElOrFirstChildIsIframe() {
if (
this.getElement.value?.tagName === 'IFRAME' ||
this.getElement.value?.firstElementChild?.tagName === 'IFRAME'
) {
return true
} else {
return false
}
}
//
//
//
selectedElementIsValidText() {
let reachedElseStatement = false
// Get all child elements of the parentDiv
const childElements = this.getElement.value?.children
if (
this.getElement.value?.tagName === 'IMG' ||
this.getElement.value?.firstElementChild?.tagName === 'IFRAME'
) {
return
}
if (!childElements) {
return
}
Array.from(childElements).forEach((element) => {
if (element?.tagName === 'IMG' || element?.tagName === 'DIV') {
reachedElseStatement = false
} else {
reachedElseStatement = true
}
})
return reachedElseStatement
}
previewCurrentDesign() {
this.pageBuilderStateStore.setElement(null)
const pagebuilder = document.querySelector('#pagebuilder')
if (!pagebuilder) return
const addedHtmlComponents = ref<string[]>([])
// preview current design in external browser tab
// iterate over each top-level section component within pagebuilder only
pagebuilder.querySelectorAll('section:not(section section)').forEach((section) => {
// remove hovered and selected
// remove hovered
const hoveredElement = section.querySelector('[hovered]')
if (hoveredElement) {
hoveredElement.removeAttribute('hovered')
}
// remove selected
const selectedElement = section.querySelector('[selected]')
if (selectedElement) {
selectedElement.removeAttribute('selected')
}
// push outer html into the array
addedHtmlComponents.value.push(section.outerHTML)
})
// stringify added html components
const stringifiedComponents = JSON.stringify(addedHtmlComponents.value)
// commit
this.pageBuilderStateStore.setCurrentLayoutPreview(stringifiedComponents)
// set added html components back to empty array
addedHtmlComponents.value = []
//
}
// Helper function to sanitize title for localStorage key
private sanitizeForLocalStorage(input: string): string {
return input
.trim() // Remove leading/trailing spaces
.toLowerCase() // Convert to lowercase
.replace(/\s+/g, '-') // Replace one or more spaces with single hyphen
.replace(/[^a-z0-9-]/g, '') // Remove all non-alphanumeric characters except hyphens
.replace(/-+/g, '-') // Replace multiple consecutive hyphens with single hyphen
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
}
updateLocalStorageItemName(): void {
const updateOrCreate = this.pageBuilderStateStore.getConfigPageBuilder?.updateOrCreate?.formType
const resourceData = this.pageBuilderStateStore.getConfigPageBuilder?.resourceData
const resourceFormName =
this.pageBuilderStateStore.getConfigPageBuilder?.updateOrCreate?.formName
// Logic for create resource
if (updateOrCreate === 'create') {
if (resourceFormName && resourceFormName.length > 0) {
this.pageBuilderStateStore.setLocalStorageItemName(
`page-builder-create-resource-${this.sanitizeForLocalStorage(resourceFormName)}`,
)
return
}
this.pageBuilderStateStore.setLocalStorageItemName(`page-builder-create-resource`)
return
}
// Logic for create
// Logic for update and with resource form name
if (updateOrCreate === 'update') {
//
//
//
if (typeof resourceFormName === 'string' && resourceFormName.length > 0) {
//
//
if (resourceData && resourceData != null && !resourceData.title) {
// Check if id is missing, null, undefined, or an empty string (after trimming)
if (!resourceData.id || typeof resourceData.id === 'string') {
this.pageBuilderStateStore.setLocalStorageItemName(
`page-builder-update-resource-${this.sanitizeForLocalStorage(resourceFormName)}`,
)
return
}
}
// Runs when resourceData has title but no ID
if (resourceData && resourceData != null) {
if (
resourceData.title &&
typeof resourceData.title === 'string' &&
resourceData.title.length > 0
) {
if (!resourceData.id || typeof resourceData.id === 'string') {
this.pageBuilderStateStore.setLocalStorageItemName(
`page-builder-update-resource-${this.sanitizeForLocalStorage(resourceFormName)}-${this.sanitizeForLocalStorage(resourceData.title)}`,
)
return
}
}
}
// Runs when resourceData has ID but no title
if (resourceData && resourceData != null) {
if (!resourceData.title && typeof resourceData.title !== 'string') {
if (resourceData.id || typeof resourceData.id === 'number') {
this.pageBuilderStateStore.setLocalStorageItemName(
`page-builder-update-resource-${this.sanitizeForLocalStorage(resourceFormName)}-${this.sanitizeForLocalStorage(String(resourceData.id))}`,
)
return
}
}
}
// Runs when resourceData has both title and ID
if (resourceData && resourceData != null) {
if (
resourceData.title &&
typeof resourceData.title === 'string' &&
resourceData.title.length > 0
) {
if (resourceData.id || typeof resourceData.id === 'number') {
this.pageBuilderStateStore.setLocalStorageItemName(
`page-builder-update-resource-${this.sanitizeForLocalStorage(resourceFormName)}-${this.sanitizeForLocalStorage(resourceData.title)}-${this.sanitizeForLocalStorage(String(resourceData.id))}`,
)
return
}
}
}
}
// Logic for update without without resourceFormName
if (
!resourceFormName ||
(typeof resourceFormName === 'string' && resourceFormName.length === 0)
) {
//
//
if (resourceData && resourceData != null && !resourceData.title) {
// Check if id is missing, null, undefined, or an empty string (after trimming)
if (!resourceData.id || typeof resourceData.id === 'string') {
this.pageBuilderStateStore.setLocalStorageItemName(`page-builder-update-resource`)
return
}
}
// Runs when resourceData has title but no ID
if (resourceData && resourceData != null) {
if (
resourceData.title &&
typeof resourceData.title === 'string' &&
resourceData.title.length > 0
) {
if (!resourceData.id || typeof resourceData.id === 'string') {
this.pageBuilderStateStore.setLocalStorageItemName(
`page-builder-update-resource-${this.sanitizeForLocalStorage(resourceData.title)}`,
)
return
}
}
}
// Runs when resourceData has ID but no title
if (resourceData && resourceData != null) {
if (!resourceData.title && typeof resourceData.title !== 'string') {
if (resourceData.id || typeof resourceData.id === 'number') {
this.pageBuilderStateStore.setLocalStorageItemName(
`page-builder-update-resource-${this.sanitizeForLocalStorage(String(resourceData.id))}`,
)
return
}
}
}
// Runs when resourceData has both title and ID
if (resourceData && resourceData != null) {
if (
resourceData.title &&
typeof resourceData.title === 'string' &&
resourceData.title.length > 0
) {
if (resourceData.id || typeof resourceData.id === 'number') {
this.pageBuilderStateStore.setLocalStorageItemName(
`page-builder-update-resource-${this.sanitizeForLocalStorage(resourceData.title)}-${this.sanitizeForLocalStorage(String(resourceData.id))}`,
)
return
}
}
}
}
}
}
/**
* Components from DOM → JS (not JS → DOM).
* Saving the current DOM state into JS this.getComponents (for example, before saving to localStorage).
* This function Only copies the current DOM HTML into JS this.getComponents (component.html_code).
*/
#domToComponentsSync = async () => {
const pagebuilder = document.querySelector('#pagebuilder')
if (!pagebuilder) return
const hoveredElement = pagebuilder.querySelector('[hovered]')
if (hoveredElement) {
hoveredElement.removeAttribute('hovered')
}
const componentsToSave: { html_code: string; id: string | null; title: string }[] = []
pagebuilder.querySelectorAll('section[data-componentid]').forEach((section) => {
componentsToSave.push({
html_code: section.outerHTML,
id: section.getAttribute('data-componentid'),
title:
section.getAttribute('title') ||
section.getAttribute('data-componentid') ||
'Untitled Component',
})
})
if (this.getLocalStorageItemName.value) {
localStorage.setItem(this.getLocalStorageItemName.value, JSON.stringify(componentsToSave))
}
// No DOM mutation here!
await new Promise<void>((resolve) => resolve())
}
// save to local storage
async saveComponentsLocalStorage() {
await this.nextTick
await this.#domToComponentsSync()
}
async removeItemComponentsLocalStorageCreate() {
await this.nextTick
if (this.getLocalStorageItemName.value) {
localStorage.removeItem(this.getLocalStorageItemName.value)
}
}
async removeItemComponentsLocalStorageUpdate() {
if (this.getLocalStorageItemName.value) {
localStorage.removeItem(this.getLocalStorageItemName.value)
}
}
areComponentsStoredInLocalStorage() {
if (!this.getLocalStorageItemName.value) return false
if (
this.getLocalStorageItemName.value &&
localStorage.getItem(this.getLocalStorageItemName.value)
) {
const savedCurrentDesign = localStorage.getItem(this.getLocalStorageItemName.value)
if (savedCurrentDesign) {
return savedCurrentDesign
}
}
return false
}
//
//
async updateBasePrimaryImage(data?: { type: string }): Promise<void> {
if (!this.getElement.value) return
// If no data provided, apply current image if available (new simplified usage)
if (this.getCurrentImage.value && this.getCurrentImage.value.src) {
await this.nextTick
this.pageBuilderStateStore.setBasePrimaryImage(`${this.getCurrentImage.value.src}`)
await this.handleAutoSave()
}
}
showBasePrimaryImage() {
if (!this.getElement.value) return
const currentImageContainer = document.createElement('div')
currentImageContainer.innerHTML = this.getElement.value.outerHTML
// Get all img and div within the current image container
const imgElements = currentImageContainer.getElementsByTagName('img')
const divElements = currentImageContainer.getElementsByTagName('div')
// Check if there is exactly one img and no div
if (imgElements.length === 1 && divElements.length === 0) {
// Return the source of the only img
this.pageBuilderStateStore.setBasePrimaryImage(imgElements[0].src)
return
}
this.pageBuilderStateStore.setBasePrimaryImage(null)
}
#addHyperlinkToElement(
hyperlinkEnable: boolean,
urlInput: string | null,
openHyperlinkInNewTab: boolean,
) {
if (!this.getElement.value) return
// Check if element is a proper DOM element and has closest method
if (
!(this.getElement.value instanceof HTMLElement) ||
typeof this.getElement.value.closest !== 'function'
)
return
const parentHyperlink = this.getElement.value.closest('a')
const hyperlink = this.getElement.value.querySelector('a')
this.pageBuilderStateStore.setHyperlinkError(null)
// url validation
const urlRegex = /^https?:\/\//
const isValidURL = ref(true)
if (hyperlinkEnable === true && urlInput !== null) {
isValidURL.value = urlRegex.test(urlInput)
}
if (isValidURL.value === false) {
this.pageBuilderStateStore.setHyperlinkMessage(null)
this.pageBuilderStateStore.setHyperlinkError('URL is not valid')
return
}
if (hyperlinkEnable === true && typeof urlInput === 'string') {
// check if element contains child hyperlink tag
// updated existing url
if (hyperlink !== null && urlInput.length !== 0) {
hyperlink.href = urlInput
// Conditionally set the target attribute if openHyperlinkInNewTab is true
if (openHyperlinkInNewTab === true) {
hyperlink.target = '_blank'
}
if (openHyperlinkInNewTab === false) {
hyperlink.removeAttribute('target')
}
hyperlink.textContent = this.getElement.value.textContent
this.pageBuilderStateStore.setHyperlinkMessage('Succesfully updated element hyperlink')
this.pageBuilderStateStore.setElementContainsHyperlink(true)
return
}
// check if element contains child a tag
if (hyperlink === null && urlInput.length !== 0) {
// add a href
if (parentHyperlink === null) {
const link = document.createElement('a')
link.href = urlInput
// Conditionally set the target attribute if openHyperlinkInNewTab is true
if (openHyperlinkInNewTab === true) {
link.target = '_blank'
}
link.textContent = this.getElement.value.textContent
this.getElement.value.textContent = ''
this.getElement.value.appendChild(link)
this.pageBuilderStateStore.setHyperlinkMessage('Successfully added hyperlink to element')
this.pageBuilderStateStore.setElementContainsHyperlink(true)
return
}
}
//
}
if (hyperlinkEnable === false && urlInput === 'removeHyperlink') {
// To remove the added hyperlink tag
const originalText = this.getElement.value.textContent || ''
const textNode = document.createTextNode(originalText)
this.getElement.value.textContent = ''
this.getElement.value.appendChild(textNode)
this.pageBuilderStateStore.setHyberlinkEnable(false)
this.pageBuilderStateStore.setElementContainsHyperlink(false)
}
}
#checkForHyperlink() {
if (!this.getElement.value) return
const hyperlink = this.getElement.value.querySelector('a')
if (hyperlink !== null) {
this.pageBuilderStateStore.setHyberlinkEnable(true)
this.pageBuilderStateStore.setElementContainsHyperlink(true)
this.pageBuilderStateStore.setHyperlinkInput(hyperlink.href)
this.pageBuilderStateStore.setHyperlinkMessage(null)
this.pageBuilderStateStore.setHyperlinkError(null)
if (hyperlink.target === '_blank') {
this.pageBuilderStateStore.setOpenHyperlinkInNewTab(true)
}
if (hyperlink.target !== '_blank') {
this.pageBuilderStateStore.setOpenHyperlinkInNewTab(false)
}
return
}
this.pageBuilderStateStore.setElementContainsHyperlink(false)
this.pageBuilderStateStore.setHyperlinkInput('')
this.pageBuilderStateStore.setHyperlinkError(null)
this.pageBuilderStateStore.setHyperlinkMessage(null)
this.pageBuilderStateStore.setHyberlinkEnable(false)
}
handleHyperlink(
hyperlinkEnable?: boolean,
urlInput?: string | null,
openHyperlinkInNewTab?: boolean,
): void {
this.pageBuilderStateStore.setHyperlinkAbility(true)
if (!this.getElement.value) return
// Check if element is a proper DOM element and has closest method
if (
!(this.getElement.value instanceof HTMLElement) ||
typeof this.getElement.value.closest !== 'function'
)
return
const parentHyperlink = this.getElement.value.closest('a')
// handle case where parent element already has an a href tag
// when clicking directly on a hyperlink
if (parentHyperlink !== null) {
this.pageBuilderStateStore.setHyperlinkAbility(false)
}
//
const elementTag = this.getElement.value?.tagName.toUpperCase()
if (
elementTag !== 'P' &&
elementTag !== 'H1' &&
elementTag !== 'H2' &&
elementTag !== 'H3' &&
elementTag !== 'H4' &&
elementTag !== 'H5' &&
elementTag !== 'H6'
) {
this.pageBuilderStateStore.setHyperlinkAbility(false)
}
if (hyperlinkEnable === undefined) {
this.#checkForHyperlink()
return
}
this.#addHyperlinkToElement(hyperlinkEnable, urlInput || null, openHyperlinkInNewTab || false)
}
// Helper method for custom components to easily add components
async addComponent(componentObject: ComponentObject): Promise<void> {
try {
const clonedComponent = this.cloneCompObjForDOMInsertion({
html_code: componentObject.html_code,
id: componentObject.id,
title: componentObject.title,
})
this.pageBuilderStateStore.setPushComponents({
component: clonedComponent,
componentArrayAddMethod: this.getComponentArrayAddMethod.value || 'push',
})
// Wait for the DOM to update before setting event listeners
await nextTick()
await this.setEventListenersForElements()
} catch (error) {
console.error('Error adding component:', error)
}
}
/**
* Parse and set components from JSON or HTML data
*
* Supports:
* - JSON: Array of ComponentObject with html_code, id, title
* - HTML: String containing <section data-componentid="..."> elements
*
* Auto-detects format and parses accordingly
*
* @param data - JSON string (e.g., '[{"html_code":"...","id":"123","title":"..."}]')
* OR HTML string (e.g., '<section data-componentid="123">...</section>')
*/
setComponentsFromData(data: string): void {
// Auto-detect if input is JSON or HTML
const trimmedData = data.trim()
if (trimmedData.startsWith('[') || trimmedData.startsWith('{')) {
// Looks like JSON - parse as JSON
this.#parseJSONComponents(trimmedData)
} else if (trimmedData.startsWith('<')) {
// Looks like HTML - parse as HTML
this.#parseHTMLComponents(trimmedData)
} else {
this.#parseJSONComponents(trimmedData)
}
}
toggleTipTapModal(status: boolean): void {
this.pageBuilderStateStore.setShowModalTipTap(status)
if (!status) {
this.handleAutoSave()
}
}
// Private method to parse JSON components
#parseJSONComponents(jsonData: string): void {
try {
const parsedData = JSON.parse(jsonData)
let savedCurrentDesign: ComponentObject[] = []
// Load ComponentObjects from parsed data
if (Array.isArray(parsedData) && parsedData.length > 0) {
// Data is always ComponentObjects with html_code, id, and title
savedCurrentDesign = parsedData
}
this.pageBuilderStateStore.setComponents(savedCurrentDesign)
} catch (error) {
console.error('Error parsing JSON components:', error)
// Set empty array on error to ensure consistent state
this.pageBuilderStateStore.setComponents([])
}
}
// Private method to parse HTML components
#parseHTMLComponents(htmlData: string): void {
try {
// Parse the HTML content using DOMParser
const parser = new DOMParser()
const doc = parser.parseFromString(htmlData, 'text/html')
// Select all <section> elements with data-componentid attribute
const sectionElements = doc.querySelectorAll('section[data-componentid]')
const extractedSections: ComponentObject[] = []
// Loop through the selected elements and extract outerHTML
sectionElements.forEach((section) => {
const htmlElement = section as HTMLElement
extractedSections.push({
html_code: htmlElement.outerHTML,
id: htmlElement.dataset.componentid || null,
title: htmlElement.title || htmlElement.dataset.componentid || 'Untitled Component',
})
})
this.pageBuilderStateStore.setComponents(extractedSections)
} catch (error) {
console.error('Error parsing HTML components:', error)
// Set empty array on error to ensure consistent state
this.pageBuilderStateStore.setComponents([])
}
}
// Load existing content from HTML when in update mode
loadExistingContent(data?: string, injectCustomHTMLSections?: boolean): void {
this.pageBuilderStateStore.setComponents([])
if (!this.pageBuilderStateStore.getConfigPageBuilder) return
if (injectCustomHTMLSections && data !== undefined) {
this.setComponentsFromData(data)
}
const storedData = this.areComponentsStoredInLocalStorage()
if (this.pageBuilderStateStore.getConfigPageBuilder?.updateOrCreate?.formType === 'create') {
if (storedData) {
this.setComponentsFromData(storedData)
}
}
if (this.pageBuilderStateStore.getConfigPageBuilder?.updateOrCreate?.formType === 'update') {
if (data) {
this.setComponentsFromData(data)
}
}
}
async handlePageBuilderMethods(): Promise<void> {
await new Promise((resolve) => requestAnimationFrame(resolve))
// handle custom URL
this.handleHyperlink(undefined, null, false)
// handle opacity
this.handleOpacity(undefined)
// handle BG opacity
this.handleBackgroundOpacity(undefined)
// displayed image
this.showBasePrimaryImage()
// border style
this.handleBorderStyle(undefined)
// border width
this.handleBorde