UNPKG

@myissue/vue-website-page-builder

Version:

Vue 3 page builder component with drag & drop functionality.

1,535 lines (1,282 loc) 51.2 kB
// 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