@myissue/vue-website-page-builder
Version:
Vue 3 page builder component with drag & drop functionality.
1,473 lines (1,300 loc) • 109 kB
text/typescript
import { LocalStorageManager } from './LocalStorageManager'
import type {
BuilderResourceData,
ComponentObject,
ImageObject,
PageBuilderConfig,
PageSettings,
StartBuilderResult,
} from '../types'
import type { usePageBuilderStateStore } from '../stores/page-builder-state'
import tailwindFontSizes from '../utils/builder/tailwind-font-sizes'
import tailwindColors from '../utils/builder/tailwaind-colors'
import tailwindOpacities from '../utils/builder/tailwind-opacities'
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 '../composables/delay'
import { isEmptyObject } from '../helpers/isEmptyObject'
import { extractCleanHTMLFromPageBuilder } from '../composables/extractCleanHTMLFromPageBuilder'
// Define available languages as a type and an array for easy iteration and type safety
export type AvailableLanguage =
| 'en'
| 'zh-Hans'
| 'fr'
| 'ja'
| 'ru'
| 'es'
| 'pt'
| 'de'
| 'ar'
| 'hi'
export const AVAILABLE_LANGUAGES: AvailableLanguage[] = [
'en',
'zh-Hans',
'fr',
'ja',
'ru',
'es',
'pt',
'de',
'ar',
'hi',
]
export class PageBuilderService {
// Class properties with types
private fontSizeRegex =
/^(sm:|md:|lg:|xl:|2xl:)?pbx-text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/
protected pageBuilderStateStore: ReturnType<typeof usePageBuilderStateStore>
private getLocalStorageItemName: ComputedRef<string | null>
private getApplyImageToSelection: ComputedRef<ImageObject>
private getHyberlinkEnable: ComputedRef<boolean>
private getComponents: ComputedRef<ComponentObject[] | null>
private getComponent: ComputedRef<ComponentObject | null>
private getElement: ComputedRef<HTMLElement | null>
private getComponentArrayAddMethod: ComputedRef<string | null>
private NoneListernesTags: string[]
private hasStartedEditing: boolean = false
// Hold data from Database or Backend for updated post
private originalComponents: BuilderResourceData | undefined = undefined
// Holds data to be mounted when pagebuilder is not yet present in the DOM
private savedMountComponents: BuilderResourceData | null = null
private pendingMountComponents: BuilderResourceData | null = null
private isPageBuilderMissingOnStart: boolean = false
// Add a class-level WeakMap to track elements and their listeners
// Use class-level WeakMap from being a local variable inside addListenersToEditableElements to a private class-level property.
// This ensures that the map persists across multiple calls to the method and retains knowledge of
// which elements already have listeners.
// This prevents multiple event listeners being attached to the same HTML elements
private elementsWithListeners = new WeakMap<
Element,
{ click: EventListener; mouseover: EventListener; mouseleave: EventListener }
>()
constructor(pageBuilderStateStore: ReturnType<typeof usePageBuilderStateStore>) {
this.hasStartedEditing = false
this.pageBuilderStateStore = pageBuilderStateStore
this.getApplyImageToSelection = computed(
() => this.pageBuilderStateStore.getApplyImageToSelection,
)
this.getLocalStorageItemName = computed(
() => this.pageBuilderStateStore.getLocalStorageItemName,
)
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.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',
'PRE',
'CODE',
'MARK',
'DEL',
'INS',
'U',
'FIGURE',
'FIGCAPTION',
]
}
/**
* Returns an array of available languages.
* @returns {AvailableLanguage[]} An array of available language codes.
*/
public availableLanguage(): AvailableLanguage[] {
return AVAILABLE_LANGUAGES
}
/**
* Sets the current language in the page builder state.
* @param {string} lang - The language code to set.
*/
public changeLanguage(lang: string) {
this.pageBuilderStateStore.setCurrentLanguage(lang)
}
/**
* Deselects any selected or hovered elements in the builder UI.
* @returns {Promise<void>}
*/
async clearHtmlSelection(): Promise<void> {
this.pageBuilderStateStore.setComponent(null)
this.pageBuilderStateStore.setElement(null)
await this.removeHoveredAndSelected()
}
/**
* Ensures that the `updateOrCreate` configuration is valid and sets default values if necessary.
* @param {PageBuilderConfig} config - The page builder configuration.
* @private
*/
private ensureUpdateOrCreateConfig(config: PageBuilderConfig): void {
// Case A: updateOrCreate is missing or an empty object
if (!config.updateOrCreate || (config.updateOrCreate && isEmptyObject(config.updateOrCreate))) {
const updatedConfig = {
...config,
updateOrCreate: {
formType: 'create',
formName: 'post',
},
} as const
this.pageBuilderStateStore.setPageBuilderConfig(updatedConfig)
return
}
// Case B: formType is valid ('create' or 'update'), but formName is missing or an empty string
if (
(config.updateOrCreate &&
typeof config.updateOrCreate.formType === 'string' &&
(config.updateOrCreate.formType === 'create' ||
config.updateOrCreate.formType === 'update') &&
typeof config.updateOrCreate.formName !== 'string') ||
(typeof config.updateOrCreate.formName === 'string' &&
config.updateOrCreate.formName.length === 0)
) {
const updatedConfig = {
...config,
updateOrCreate: {
formType: config.updateOrCreate.formType,
formName: 'post',
},
} as const
this.pageBuilderStateStore.setPageBuilderConfig(updatedConfig)
}
// Case C: formType is missing or not a valid string like ('create' or 'update') but formName is valid string
if (
(config.updateOrCreate && typeof config.updateOrCreate.formType !== 'string') ||
(typeof config.updateOrCreate.formType === 'string' &&
config.updateOrCreate.formType !== 'create' &&
config.updateOrCreate.formType !== 'update' &&
typeof config.updateOrCreate.formName === 'string' &&
config.updateOrCreate.formName.length !== 0)
) {
const updatedConfig = {
...config,
updateOrCreate: {
formType: 'create',
formName: config.updateOrCreate.formName,
},
} as const
this.pageBuilderStateStore.setPageBuilderConfig(updatedConfig)
return
}
// Case D: formType exists but is not 'create' or 'update', and formName is missing or invalid
if (
config.updateOrCreate &&
typeof config.updateOrCreate.formType === 'string' &&
config.updateOrCreate.formType !== 'create' &&
config.updateOrCreate.formType !== 'update' &&
typeof config.formName !== 'string'
) {
const updatedConfig = {
...config,
updateOrCreate: {
formType: 'create',
formName: 'post',
},
} as const
this.pageBuilderStateStore.setPageBuilderConfig(updatedConfig)
}
}
/**
* Validates the user-provided components array.
* @param {unknown} components - The components data to validate.
* @returns {{error: true, warning: string, status: string} | {error: true, reason: string} | undefined} An error object if validation fails, otherwise undefined.
* @private
*/
private validateUserProvidedComponents(components: unknown) {
const formType =
this.pageBuilderStateStore.getPageBuilderConfig &&
this.pageBuilderStateStore.getPageBuilderConfig.updateOrCreate &&
this.pageBuilderStateStore.getPageBuilderConfig.updateOrCreate.formType
if (
Array.isArray(components) &&
components.length >= 1 &&
formType === 'create' &&
components
) {
return {
error: true as const,
warning:
'You cannot set formType to create in your configuration while also passing a components data array to the Page Builder. Please set formType to update.',
status: 'validation_failed',
}
}
// Must be an array
if (!Array.isArray(components)) {
return {
error: true as const,
reason: 'Components data must be an array.',
}
}
// Check that the first item looks like a component
const first = components[0]
if (first && 'html_code' in first && typeof first.html_code !== 'string') {
return {
error: true as const,
reason: "The 'html_code' property in the first object must be a string.",
}
}
// Check that the first item has an 'html_code' key
if (Array.isArray(components) && components.length >= 1) {
if (!first || !('html_code' in first)) {
return {
error: true as const,
reason: "The first object in the array must include an 'html_code' key.",
}
}
}
// No errors found
return
}
/**
* Ensures that the language configuration is valid and sets default values if necessary.
* @param {PageBuilderConfig} config - The page builder configuration.
* @private
*/
private ensureLanguage(config: PageBuilderConfig): void {
// Set default language config if missing, empty, or language missing/empty
const defaultLang = 'en'
const defaultEnable = ['en', 'zh-Hans', 'fr', 'ja', 'ru', 'es', 'pt', 'de', 'ar', 'hi'] as const
let needsDefault = false
const userSettings = config.userSettings
const language = userSettings && userSettings.language
if (!userSettings || isEmptyObject(userSettings)) {
needsDefault = true
} else if (!language || isEmptyObject(language)) {
needsDefault = true
}
if (needsDefault) {
const updatedLanguage = {
...config,
userSettings: {
...userSettings,
language: {
default: defaultLang,
enable: defaultEnable as typeof defaultEnable,
},
},
} as const
this.pageBuilderStateStore.setPageBuilderConfig(updatedLanguage)
return
}
// Ensure default is in enable array
if (language && Array.isArray(language.enable) && language.default) {
if (!language.enable.includes(language.default)) {
const updatedEnable = [...language.enable, language.default]
const updatedLanguage = {
...config,
userSettings: {
...userSettings,
language: {
...language,
enable: updatedEnable,
},
},
} as const
this.pageBuilderStateStore.setPageBuilderConfig(updatedLanguage)
}
}
}
/**
* Validates the entire page builder configuration.
* @param {PageBuilderConfig} config - The page builder configuration.
* @private
*/
private validateConfig(config: PageBuilderConfig): void {
const defaultConfigValues = {
updateOrCreate: {
formType: 'create',
formName: 'post',
},
} as const
// Set config for page builder if not set by user
if (!config || (config && Object.keys(config).length === 0 && config.constructor === Object)) {
this.pageBuilderStateStore.setPageBuilderConfig(defaultConfigValues)
}
if (config && Object.keys(config).length !== 0 && config.constructor === Object) {
this.ensureUpdateOrCreateConfig(config)
}
this.ensureLanguage(config)
}
/**
* Saves user settings to local storage.
* @param {string} newLang - The new language to save.
*/
public saveUserSettingsStorage(newLang: string) {
localStorage.setItem(
'userSettingsPageBuilder',
JSON.stringify({ userSettings: { lang: newLang } }),
)
}
/**
* Initializes the Page Builder.
* @param {PageBuilderConfig} config - The configuration object for the Page Builder.
* @param {BuilderResourceData} [passedComponentsArray] - Optional array of components to load.
* @returns {Promise<StartBuilderResult>} A result object indicating success or failure.
*/
async startBuilder(
config: PageBuilderConfig,
passedComponentsArray?: BuilderResourceData,
): Promise<StartBuilderResult> {
// Reactive flag signals to the UI that the builder has been successfully initialized
// Prevents builder actions to prevent errors caused by missing DOM .
this.pageBuilderStateStore.setBuilderStarted(true)
const pagebuilder = document.querySelector('#pagebuilder')
let validation
try {
this.originalComponents = passedComponentsArray
this.pageBuilderStateStore.setPageBuilderConfig(config)
// Validate and normalize the config (ensure required fields are present)
this.validateConfig(config)
validation = this.validateUserProvidedComponents(passedComponentsArray)
// Update the localStorage key name based on the config/resource
this.updateLocalStorageItemName()
this.initializeHistory()
if (passedComponentsArray) {
this.savedMountComponents = passedComponentsArray
}
// Page Builder is not Present in the DOM but Components have been passed to the Builder
if (!pagebuilder) {
this.isPageBuilderMissingOnStart = true
}
if (passedComponentsArray && !pagebuilder) {
this.pendingMountComponents = passedComponentsArray
}
// Page Builder is Present in the DOM & Components have been passed to the Builder
if (pagebuilder) {
this.completeBuilderInitialization(passedComponentsArray)
}
// result to end user
const result: StartBuilderResult = {
message: 'Page builder started successfully.',
}
if (validation) {
result.validation = validation
}
// PassedComponentsArray
if (Array.isArray(passedComponentsArray) && passedComponentsArray.length >= 0) {
result.passedComponentsArray = passedComponentsArray
}
// Return messages, validation info if present etc.
return result
} catch (err) {
console.error('Not able to start the Page Builder', err)
return {
error: true as const,
reason: 'Failed to start the Page Builder due to an unexpected error.',
}
}
}
/**
* Completes the builder initialization process once the DOM is ready.
* @param {BuilderResourceData} [passedComponentsArray] - Optional array of components to load.
* @returns {Promise<void>}
*/
async completeBuilderInitialization(passedComponentsArray?: BuilderResourceData): Promise<void> {
this.pageBuilderStateStore.setIsLoadingGlobal(true)
await delay(400)
// Always clear DOM and store before mounting new resource
this.deleteAllComponentsFromDOM()
const config = this.pageBuilderStateStore.getPageBuilderConfig
const formType = config && config.updateOrCreate && config.updateOrCreate.formType
const localStorageData = this.getSavedPageHtml()
// Deselect any selected or hovered elements in the builder UI
await this.clearHtmlSelection()
if (formType === 'update' || formType === 'create') {
// Page Builder is initially present in the DOM
if (!this.pendingMountComponents) {
if (!passedComponentsArray && this.isPageBuilderMissingOnStart && localStorageData) {
await this.completeMountProcess(localStorageData)
return
}
if (passedComponentsArray && !localStorageData) {
const htmlString = this.renderComponentsToHtml(passedComponentsArray)
await this.completeMountProcess(htmlString, true)
this.saveDomComponentsToLocalStorage()
return
}
if (passedComponentsArray && localStorageData) {
const htmlString = this.renderComponentsToHtml(passedComponentsArray)
await this.completeMountProcess(htmlString, true)
await delay(500)
this.pageBuilderStateStore.setHasLocalDraftForUpdate(true)
return
}
if (!passedComponentsArray && localStorageData && !this.savedMountComponents) {
await this.completeMountProcess(localStorageData)
return
}
if (!passedComponentsArray && this.savedMountComponents && localStorageData) {
const htmlString = this.renderComponentsToHtml(this.savedMountComponents)
await this.completeMountProcess(htmlString)
return
}
if (!passedComponentsArray && !localStorageData && this.isPageBuilderMissingOnStart) {
const htmlString = this.renderComponentsToHtml([])
await this.completeMountProcess(htmlString)
return
}
if (!this.isPageBuilderMissingOnStart && !localStorageData && !passedComponentsArray) {
const htmlString = this.renderComponentsToHtml([])
await this.completeMountProcess(htmlString)
return
}
}
// Page Builder is not initially present in the DOM
if (this.pendingMountComponents) {
if (localStorageData && this.isPageBuilderMissingOnStart) {
const htmlString = this.renderComponentsToHtml(this.pendingMountComponents)
await this.completeMountProcess(htmlString, true)
await delay(500)
this.pageBuilderStateStore.setHasLocalDraftForUpdate(true)
this.pendingMountComponents = null
return
}
if (!localStorageData && passedComponentsArray && this.isPageBuilderMissingOnStart) {
const htmlString = this.renderComponentsToHtml(this.pendingMountComponents)
await this.completeMountProcess(htmlString, true)
this.saveDomComponentsToLocalStorage()
return
}
if (!passedComponentsArray && !localStorageData && this.isPageBuilderMissingOnStart) {
const htmlString = this.renderComponentsToHtml(this.pendingMountComponents)
await this.completeMountProcess(htmlString, true)
this.saveDomComponentsToLocalStorage()
return
}
}
}
}
/**
* Converts an array of ComponentObject into a single HTML string.
*
* @returns {string} A single HTML string containing all components.
*/
private renderComponentsToHtml(componentsArray: BuilderResourceData): string {
// If the componentsArray is empty or invalid, return a default HTML structure
if (!componentsArray || (Array.isArray(componentsArray) && componentsArray.length === 0)) {
return `<div id="pagebuilder" class="pbx-text-black pbx-font-sans"></div>`
}
const sectionsHtml = componentsArray
.map((component) => {
return component.html_code // Fallback in case section is not found
})
.join('\n')
// Return the combined HTML string
return sectionsHtml
}
/**
* Completes the mounting process by loading components into the DOM and setting up listeners.
* @param {string} html - The HTML string of components to mount.
* @param {boolean} [useConfigPageSettings] - Whether to use page settings from the passed data.
* @private
*/
private async completeMountProcess(html: string, useConfigPageSettings?: boolean) {
await this.mountComponentsToDOM(html, useConfigPageSettings)
// Clean up any old localStorage items related to previous builder sessions
this.deleteOldPageBuilderLocalStorage()
this.pageBuilderStateStore.setIsRestoring(false)
this.pageBuilderStateStore.setIsLoadingGlobal(false)
}
/**
* Applies CSS class changes to the currently selected element.
* @param {string | undefined} cssUserSelection - The user's CSS class selection.
* @param {string[]} CSSArray - The array of possible CSS classes for this property.
* @param {string} mutationName - The name of the store mutation to call.
* @returns {string | undefined} The previously applied CSS class.
* @private
*/
private applyElementClassChanges(
cssUserSelection: 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 cssUserSelection is undefined, just set the current state and return
if (cssUserSelection === undefined) {
if (typeof mutationName === 'string' && mutationName.length > 2) {
// Use a type-safe approach to handle mutationName
if (
mutationName in this.pageBuilderStateStore &&
typeof this.pageBuilderStateStore[
mutationName as keyof typeof this.pageBuilderStateStore
] === 'function'
) {
const mutationFunction = this.pageBuilderStateStore[
mutationName as keyof typeof this.pageBuilderStateStore
] as (arg: string) => void
mutationFunction(elementClass)
}
}
return currentCSS
}
// cssUserSelection examples: bg-zinc-200, px-10, rounded-full etc.
if (typeof cssUserSelection === 'string' && cssUserSelection !== 'none') {
if (elementClass && currentHTMLElement.classList.contains(elementClass)) {
currentHTMLElement.classList.remove(elementClass)
}
currentHTMLElement.classList.add(cssUserSelection)
elementClass = cssUserSelection
} else if (
typeof cssUserSelection === 'string' &&
cssUserSelection === 'none' &&
elementClass
) {
currentHTMLElement.classList.remove(elementClass)
elementClass = cssUserSelection
}
// Only call store mutations after all DOM manipulation is complete
if (typeof mutationName === 'string' && mutationName.length > 2) {
// Use a type-safe approach to handle mutationName
if (
mutationName in this.pageBuilderStateStore &&
typeof this.pageBuilderStateStore[
mutationName as keyof typeof this.pageBuilderStateStore
] === 'function'
) {
const mutationFunction = this.pageBuilderStateStore[
mutationName as keyof typeof this.pageBuilderStateStore
] as (arg: string) => void
mutationFunction(elementClass)
this.pageBuilderStateStore.setElement(currentHTMLElement)
}
}
return currentCSS
}
/**
* Removes all CSS classes from the main page builder container.
* @returns {Promise<void>}
*/
public async clearClassesFromPage() {
const pagebuilder = document.querySelector('#pagebuilder')
if (!pagebuilder) return
pagebuilder.removeAttribute('class')
this.initializeElementStyles()
await nextTick()
}
/**
* Removes all inline styles from the main page builder container.
* @returns {Promise<void>}
*/
public async clearInlineStylesFromPage() {
const pagebuilder = document.querySelector('#pagebuilder')
if (!pagebuilder) return
pagebuilder.removeAttribute('style')
this.initializeElementStyles()
await nextTick()
}
/**
* Selects the main page builder container for global styling.
* @returns {Promise<void>}
*/
public async globalPageStyles() {
const pagebuilder = document.querySelector('#pagebuilder')
if (!pagebuilder) return
// Deselect any selected or hovered elements in the builder UI
await this.clearHtmlSelection()
//
// Set the element in the store
this.pageBuilderStateStore.setElement(pagebuilder as HTMLElement)
// Add the data attribute for styling
pagebuilder.setAttribute('data-global-selected', 'true')
await nextTick()
}
/**
* Handles changes to the font weight of the selected element.
* @param {string} [userSelectedFontWeight] - The selected font weight class.
*/
public handleFontWeight(userSelectedFontWeight?: string): void {
this.applyElementClassChanges(
userSelectedFontWeight,
tailwindFontStyles.fontWeight,
'setFontWeight',
)
}
/**
* Handles changes to the base font size of the selected element.
* @param {string} [userSelectedFontSize] - The selected font size class.
*/
public handleFontSizeBase(userSelectedFontSize?: string): void {
this.applyElementClassChanges(userSelectedFontSize, tailwindFontSizes.fontBase, 'setFontBase')
}
/**
* Handles changes to the desktop font size of the selected element.
* @param {string} [userSelectedFontSize] - The selected font size class for desktop.
*/
public handleFontSizeDesktop(userSelectedFontSize?: string): void {
const currentHTMLElement = this.getElement.value
if (!currentHTMLElement) return
// Hardcoded mapping: selected => base
const fontSizeBaseMap: Record<string, string> = {
'pbx-text-9xl': 'pbx-text-6xl',
'pbx-text-8xl': 'pbx-text-5xl',
'pbx-text-7xl': 'pbx-text-4xl',
'pbx-text-6xl': 'pbx-text-3xl',
'pbx-text-5xl': 'pbx-text-3xl',
'pbx-text-4xl': 'pbx-text-2xl',
'pbx-text-3xl': 'pbx-text-1xl',
'pbx-text-2xl': 'pbx-text-lg',
'pbx-text-xl': 'pbx-text-base',
'pbx-text-lg': 'pbx-text-sm',
'pbx-text-base': 'pbx-text-xs',
'pbx-text-sm': 'pbx-text-xs',
'pbx-text-xs': 'pbx-text-xs',
}
if (userSelectedFontSize) {
// Remove all existing font size classes first
Array.from(currentHTMLElement.classList).forEach((cls) => {
if (this.fontSizeRegex.test(cls)) {
currentHTMLElement.classList.remove(cls)
}
})
// Extract the font size class (remove 'lg:' if present)
const fontSizeClass = userSelectedFontSize.replace(/^lg:/, '')
const baseClass = fontSizeBaseMap[fontSizeClass] || fontSizeClass
const lgClass = `lg:${fontSizeClass}`
if (baseClass !== fontSizeClass) {
currentHTMLElement.classList.add(baseClass, lgClass)
} else {
currentHTMLElement.classList.add(baseClass)
}
}
const currentCSS = tailwindFontSizes.fontDesktop.find((CSS) => {
return currentHTMLElement.classList.contains(CSS)
})
if (!userSelectedFontSize) {
this.pageBuilderStateStore.setFontDesktop('none')
}
if (currentCSS && !userSelectedFontSize) {
this.pageBuilderStateStore.setFontDesktop(currentCSS)
}
}
/**
* Applies helper CSS classes to elements, such as wrapping them or adding responsive text classes.
* @param {HTMLElement} element - The element to process.
* @private
*/
private applyHelperCSSToElements(element: HTMLElement): void {
this.wrapElementInDivIfExcluded(element)
// If this is a DIV and its only/main child is a heading, apply font size classes to the DIV
if (
element.tagName === 'DIV' &&
element.children.length === 1 &&
['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(element.children[0].tagName)
) {
const heading = element.children[0] as HTMLElement
element.classList.forEach((cls) => {
if (this.fontSizeRegex.test(cls)) {
element.classList.remove(cls)
}
})
// Apply responsive font size classes based on heading type
if (heading.tagName === 'H2') {
element.classList.add('pbx-text-2xl', 'lg:pbx-text-4xl', 'pbx-font-medium')
}
if (heading.tagName === 'H3') {
element.classList.add('pbx-text-1xl', 'lg:pbx-text-3xl', 'pbx-font-medium')
}
}
}
/**
* Toggles the visibility of the TipTap modal for rich text editing.
* @param {boolean} status - Whether to show or hide the modal.
* @returns {Promise<void>}
*/
public async toggleTipTapModal(status: boolean): Promise<void> {
this.pageBuilderStateStore.setShowModalTipTap(status)
// Wait for Vue to finish DOM updates before attaching event listeners. This ensure elements exist in the DOM.
await nextTick()
// Attach event listeners to all editable elements in the Builder
await this.addListenersToEditableElements()
if (!status) {
await this.handleAutoSave()
}
}
/**
* Wraps an element in a div if it's an excluded tag and adjacent to an image.
* @param {HTMLElement} element - The element to potentially wrap.
* @private
*/
private 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)
}
}
/**
* Handles the mouseover event for editable elements, showing a hover state.
* @param {Event} e - The mouse event.
* @param {HTMLElement} element - The element being hovered over.
* @private
*/
private 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', '')
}
}
/**
* Handles the mouseleave event for editable elements, removing the hover state.
* @param {Event} e - The mouse event.
* @private
*/
private 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')
}
}
/**
* Checks if an element is editable based on its tag name.
* @param {Element | null} el - The element to check.
* @returns {boolean} True if the element is editable, false otherwise.
*/
public isEditableElement(el: Element | null): boolean {
if (!el) return false
return !this.NoneListernesTags.includes(el.tagName)
}
/**
* Attaches click, mouseover, and mouseleave event listeners to all editable elements in the page builder.
* @private
*/
private addListenersToEditableElements = async () => {
const pagebuilder = document.querySelector('#pagebuilder')
if (!pagebuilder) return
// Wait for the next DOM update cycle to ensure all elements are rendered.
await nextTick()
pagebuilder.querySelectorAll('section *').forEach((element) => {
if (this.isEditableElement(element)) {
const htmlElement = element as HTMLElement
// If the element already has listeners, remove them to avoid duplicates.
if (this.elementsWithListeners.has(htmlElement)) {
const listeners = this.elementsWithListeners.get(htmlElement)
if (listeners) {
htmlElement.removeEventListener('click', listeners.click)
htmlElement.removeEventListener('mouseover', listeners.mouseover)
htmlElement.removeEventListener('mouseleave', listeners.mouseleave)
}
}
// Define new listener functions.
const clickListener = (e: Event) => this.handleElementClick(e, htmlElement)
const mouseoverListener = (e: Event) => this.handleMouseOver(e, htmlElement)
const mouseleaveListener = (e: Event) => this.handleMouseLeave(e)
// Add the new event listeners.
htmlElement.addEventListener('click', clickListener)
htmlElement.addEventListener('mouseover', mouseoverListener)
htmlElement.addEventListener('mouseleave', mouseleaveListener)
// Store the new listeners in the WeakMap to track them.
this.elementsWithListeners.set(htmlElement, {
click: clickListener,
mouseover: mouseoverListener,
mouseleave: mouseleaveListener,
})
}
})
}
/**
* Handles the click event for editable elements, setting the element as selected.
* @param {Event} e - The click event.
* @param {HTMLElement} element - The clicked element.
* @private
*/
private 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)
await this.handleAutoSave()
}
private getHistoryBaseKey(): string | null {
return this.getLocalStorageItemName.value
}
private initializeHistory() {
const baseKey = this.getHistoryBaseKey()
if (baseKey) {
const history = LocalStorageManager.getHistory(baseKey)
this.pageBuilderStateStore.setHistoryIndex(history.length - 1)
this.pageBuilderStateStore.setHistoryLength(history.length)
}
}
/**
* Triggers an auto-save of the current page builder content to local storage if enabled.
*/
public handleAutoSave = async () => {
this.startEditing()
const passedConfig = this.pageBuilderStateStore.getPageBuilderConfig
// 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)
// Deselect any selected or hovered elements in the builder UI
//
this.saveDomComponentsToLocalStorage()
await delay(400)
} catch (err) {
console.error('Error trying auto save.', err)
} finally {
this.pageBuilderStateStore.setIsSaving(false)
}
}
}
if (passedConfig && !passedConfig.userSettings) {
try {
this.pageBuilderStateStore.setIsSaving(true)
this.saveDomComponentsToLocalStorage()
await delay(400)
} catch (err) {
console.error('Error trying saving.', err)
} finally {
this.pageBuilderStateStore.setIsSaving(false)
}
}
}
/**
* Manually saves the current page builder content to local storage.
*/
public handleManualSave = async (doNoClearHTML?: boolean) => {
this.pageBuilderStateStore.setIsSaving(true)
if (!doNoClearHTML) {
this.clearHtmlSelection()
}
this.startEditing()
this.saveDomComponentsToLocalStorage()
await delay(300)
this.pageBuilderStateStore.setIsSaving(false)
}
/**
* Clones a component object and prepares it for insertion into the DOM by adding unique IDs and prefixes.
* @param {ComponentObject} componentObject - The component object to clone.
* @returns {ComponentObject} The cloned and prepared component object.
*/
public cloneCompObjForDOMInsertion(componentObject: ComponentObject): ComponentObject {
// Deep clone clone component
const clonedComponent = { ...componentObject }
const pageBuilderWrapper = document.querySelector('#page-builder-wrapper')
// scoll to top or bottom
if (pageBuilderWrapper) {
// push to top
if (this.getComponentArrayAddMethod.value === 'unshift') {
pageBuilderWrapper.scrollTo({
top: 0,
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) {
// Prefix all classes inside the section
section.querySelectorAll('[class]').forEach((el) => {
el.setAttribute(
'class',
this.addTailwindPrefixToClasses(el.getAttribute('class') || '', 'pbx-'),
)
})
// Generate a unique ID using uuidv4() and assign it to the section
section.dataset.componentid = uuidv4()
// Set the title attribute if present
if (clonedComponent.title) {
section.setAttribute('data-component-title', clonedComponent.title)
}
// 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
}
/**
* Removes the 'hovered' and 'selected' attributes from all elements in the page builder.
* @private
*/
private async removeHoveredAndSelected() {
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')
}
}
/**
* Syncs the CSS classes of the currently selected element to the state store.
* @private
*/
private async syncCurrentClasses() {
// convert classList to array
const classListArray = Array.from(this.getElement.value?.classList || [])
// commit array to store
this.pageBuilderStateStore.setCurrentClasses(classListArray)
}
/**
* Syncs the inline styles of the currently selected element to the state store.
* @private
*/
private async syncCurrentStyles() {
const style = this.getElement.value?.getAttribute('style')
if (style) {
const stylesObject = this.parseStyleString(style)
this.pageBuilderStateStore.setCurrentStyles(stylesObject)
} else {
this.pageBuilderStateStore.setCurrentStyles({})
}
}
/**
* Adds a CSS class to the currently selected element.
* @param {string} userSelectedClass - The class to add.
*/
public handleAddClasses(userSelectedClass: string): void {
if (
typeof userSelectedClass === 'string' &&
userSelectedClass.trim() !== '' &&
!userSelectedClass.includes(' ') &&
// Check if class (with prefix) already exists
!this.getElement.value?.classList.contains('pbx-' + userSelectedClass.trim())
) {
const cleanedClass = userSelectedClass.trim()
// Add prefix if missing
const prefixedClass = cleanedClass.startsWith('pbx-') ? cleanedClass : 'pbx-' + cleanedClass
this.getElement.value?.classList.add(prefixedClass)
this.pageBuilderStateStore.setElement(this.getElement.value)
this.pageBuilderStateStore.setClass(prefixedClass)
}
}
/**
* Adds or updates an inline style property on the currently selected element.
* @param {string} property - The CSS property to add/update.
* @param {string} value - The value of the CSS property.
*/
public handleAddStyle(property: string, value: string): void {
const element = this.getElement.value
if (!element || !property || !value) return
element.style.setProperty(property, value)
this.pageBuilderStateStore.setElement(element)
}
/**
* Removes an inline style property from the currently selected element.
* @param {string} property - The CSS property to remove.
*/
public handleRemoveStyle(property: string): void {
const element = this.getElement.value
if (!element || !property) return
element.style.removeProperty(property)
this.pageBuilderStateStore.setElement(element)
}
/**
* Handles changes to the font family of the selected element.
* @param {string} [userSelectedFontFamily] - The selected font family class.
*/
public handleFontFamily(userSelectedFontFamily?: string): void {
this.applyElementClassChanges(
userSelectedFontFamily,
tailwindFontStyles.fontFamily,
'setFontFamily',
)
}
/**
* Handles changes to the font style of the selected element.
* @param {string} [userSelectedFontStyle] - The selected font style class.
*/
public handleFontStyle(userSelectedFontStyle?: string): void {
this.applyElementClassChanges(
userSelectedFontStyle,
tailwindFontStyles.fontStyle,
'setFontStyle',
)
}
/**
* Handles changes to the vertical padding of the selected element.
* @param {string} [userSelectedVerticalPadding] - The selected vertical padding class.
*/
public handleVerticalPadding(userSelectedVerticalPadding?: string): void {
this.applyElementClassChanges(
userSelectedVerticalPadding,
tailwindPaddingAndMargin.verticalPadding,
'setFontVerticalPadding',
)
}
/**
* Handles changes to the horizontal padding of the selected element.
* @param {string} [userSelectedHorizontalPadding] - The selected horizontal padding class.
*/
public handleHorizontalPadding(userSelectedHorizontalPadding?: string): void {
this.applyElementClassChanges(
userSelectedHorizontalPadding,
tailwindPaddingAndMargin.horizontalPadding,
'setFontHorizontalPadding',
)
}
/**
* Handles changes to the vertical margin of the selected element.
* @param {string} [userSelectedVerticalMargin] - The selected vertical margin class.
*/
public handleVerticalMargin(userSelectedVerticalMargin?: string): void {
this.applyElementClassChanges(
userSelectedVerticalMargin,
tailwindPaddingAndMargin.verticalMargin,
'setFontVerticalMargin',
)
}
/**
* Handles changes to the horizontal margin of the selected element.
* @param {string} [userSelectedHorizontalMargin] - The selected horizontal margin class.
*/
public handleHorizontalMargin(userSelectedHorizontalMargin?: string): void {
this.applyElementClassChanges(
userSelectedHorizontalMargin,
tailwindPaddingAndMargin.horizontalMargin,
'setFontHorizontalMargin',
)
}
/**
* Handles changes to the border style of the selected element.
* @param {string} [borderStyle] - The selected border style class.
*/
public handleBorderStyle(borderStyle?: string): void {
this.applyElementClassChanges(
borderStyle,
tailwindBorderStyleWidthPlusColor.borderStyle,
'setBorderStyle',
)
}
/**
* Handles changes to the border width of the selected element.
* @param {string} [borderWidth] - The selected border width class.
*/
public handleBorderWidth(borderWidth?: string): void {
this.applyElementClassChanges(
borderWidth,
tailwindBorderStyleWidthPlusColor.borderWidth,
'setBorderWidth',
)
}
/**
* Handles changes to the border color of the selected element.
* @param {string} [borderColor] - The selected border color class.
*/
public handleBorderColor(borderColor?: string): void {
this.applyElementClassChanges(
borderColor,
tailwindBorderStyleWidthPlusColor.borderColor,
'setBorderColor',
)
}
// border color, style & width / end
/**
* Handles changes to the background color of the selected element.
* @param {string} [color] - The selected background color class.
*/
public handleBackgroundColor(color?: string): void {
this.applyElementClassChanges(
color,
tailwindColors.backgroundColorVariables,
'setBackgroundColor',
)
}
/**
* Handles changes to the text color of the selected element.
* @param {string} [color] - The selected text color class.
*/
public handleTextColor(color?: string): void {
this.applyElementClassChanges(color, tailwindColors.textColorVariables, 'setTextColor')
}
/**
* Handles changes to the global border radius of the selected element.
* @param {string} [borderRadiusGlobal] - The selected global border radius class.
*/
handleBorderRadiusGlobal(borderRadiusGlobal?: string): void {
this.applyElementClassChanges(
borderRadiusGlobal,
tailwindBorderRadius.roundedGlobal,
'setBorderRadiusGlobal',
)
}
/**
* Handles changes to the top-left border radius of the selected element.
* @param {string} [borderRadiusTopLeft] - The selected top-left border radius class.
*/
handleBorderRadiusTopLeft(borderRadiusTopLeft?: string): void {
this.applyElementClassChanges(
borderRadiusTopLeft,
tailwindBorderRadius.roundedTopLeft,
'setBorderRadiusTopLeft',
)
}
/**
* Handles changes to the top-right border radius of the selected element.
* @param {string} [borderRadiusTopRight] - The selected top-right border radius class.
*/
handleBorderRadiusTopRight(borderRadiusTopRight?: string): void {
this.applyElementClassChanges(
borderRadiusTopRight,
tailwindBorderRadius.roundedTopRight,
'setBorderRadiusTopRight',
)
}
/**
* Handles changes to the bottom-left border radius of the selected element.
* @param {string} [borderRadiusBottomleft] - The selected bottom-left border radius class.
*/
handleBorderRadiusBottomleft(borderRadiusBottomleft?: string): void {
this.applyElementClassChanges(
borderRadiusBottomleft,
tailwindBorderRadius.roundedBottomLeft,
'setBorderRadiusBottomleft',
)
}
/**
* Handles changes to the bottom-right border radius of the selected element.
* @param {string} [borderRadiusBottomRight] - The selected bottom-right border radius class.
*/
handleBorderRadiusBottomRight(borderRadiusBottomRight?: string): void {
this.applyElementClassChanges(
borderRadiusBottomRight,
tailwindBorderRadius.roundedBottomRight,
'setBorderRadiusBottomRight',
)
}
// border radius / end
/**
* Handles changes to the tablet font size of the selected element.
* @param {string} [userSelectedFontSize] - The selected font size class for tablet.
*/
handleFontSizeTablet(userSelectedFontSize?: string): void {
this.applyElementClassChanges(
userSelectedFontSize,
tailwindFontSizes.fontTablet,
'setFontTablet',
)
}
/**
* Handles changes to the mobile font size of the selected element.
* @param {string} [userSelectedFontSize] - The selected font size class for mobile.
*/
handleFontSizeMobile(userSelectedFontSize?: string): void {
this.applyElementClassChanges(
userSelectedFontSize,
tailwindFontSizes.fontMobile,
'setFontMobile',
)
}
/**
* Handles changes to the background opacity of the selected element.
* @param {string} [opacity] - The selected background opacity class.
*/
handleBackgroundOpacity(opacity?: string): void {
this.applyElementClassChanges(
opacity,
tailwindOpacities.backgroundOpacities,
'setBackgroundOpacity',
)
}
/**
* Handles changes to the opacity of the selected element.
* @param {string} [opacity] - The selected opacity class.
*/
handleOpacity(opacity?: string): void {
this.applyElementClassChanges(opacity, tailwindOpacities.opacities, 'setOpacity')
}
/**
* Removes all components from both the builder state and the DOM.
* @private
*/
private deleteAllComponentsFromDOM() {
// Clear the store
this.pageBuilderStateStore.setComponents([])
// Also clear the DOM
const pagebuilder = document.querySelector('#pagebuilder')
if (pagebuilder) {
// Remove all section elements (assuming each component is a <section>)
pagebuilder
.querySelectorAll('section[data-componentid]')
.forEach((section) => section.remove())
}
}
public async undo() {
this.pageBuilderStateStore.setIsLoadingGlobal(true)
await delay(300)
const baseKey = this.getHistoryBaseKey()
if (!baseKey) return
const history = LocalStorageManager.getHistory(baseKey)
if (history.length > 1 && this.pageBuilderStateStore.getHistoryIndex > 0) {
this.pageBuilderStateStore.setHistoryIndex(this.pageBuilderStateStore.getHistoryIndex - 1)
const data = history[this.pageBuilderStateStore.getHistoryIndex]
const htmlString = this.renderComponentsToHtml(data.components)
await this.mountComponentsToDOM(htmlString, false, data.pageSettings)
}
this.pageBuilderStateStore.setIsLoadingGlobal(false)
}
public async redo() {
this.pageBuilderStateStore.setIsLoading