UNPKG

@myissue/vue-website-page-builder

Version:

Vue 3 page builder component with drag & drop functionality.

1,473 lines (1,300 loc) 109 kB
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