UNPKG

@dotcms/uve

Version:

Official JavaScript library for interacting with Universal Visual Editor (UVE)

1,174 lines (1,165 loc) 38.1 kB
import { UVEEventType, UVE_MODE, DotCMSUVEAction } from '@dotcms/types'; import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal'; /** * Calculates the bounding information for each page element within the given containers. * * @export * @param {HTMLDivElement[]} containers - An array of HTMLDivElement representing the containers. * @return {DotCMSContainerBound[]} An array of objects containing the bounding information for each page element. * @example * ```ts * const containers = document.querySelectorAll('.container'); * const bounds = getDotCMSPageBounds(containers); * console.log(bounds); * ``` */ function getDotCMSPageBounds(containers) { return containers.map(container => { const containerRect = container.getBoundingClientRect(); const contentlets = Array.from(container.querySelectorAll('[data-dot-object="contentlet"]')); return { x: containerRect.x, y: containerRect.y, width: containerRect.width, height: containerRect.height, payload: JSON.stringify({ container: getDotCMSContainerData(container) }), contentlets: getDotCMSContentletsBound(containerRect, contentlets) }; }); } /** * Calculates the bounding information for each contentlet inside a container. * * @export * @param {DOMRect} containerRect - The bounding rectangle of the container. * @param {HTMLDivElement[]} contentlets - An array of HTMLDivElement representing the contentlets. * @return {DotCMSContentletBound[]} An array of objects containing the bounding information for each contentlet. * @example * ```ts * const containerRect = container.getBoundingClientRect(); * const contentlets = container.querySelectorAll('.contentlet'); * const bounds = getDotCMSContentletsBound(containerRect, contentlets); * console.log(bounds); // Element bounds within the container * ``` */ function getDotCMSContentletsBound(containerRect, contentlets) { return contentlets.map(contentlet => { const contentletRect = contentlet.getBoundingClientRect(); return { x: 0, y: contentletRect.y - containerRect.y, width: contentletRect.width, height: contentletRect.height, payload: JSON.stringify({ container: contentlet.dataset?.['dotContainer'] ? JSON.parse(contentlet.dataset?.['dotContainer']) : getClosestDotCMSContainerData(contentlet), contentlet: { identifier: contentlet.dataset?.['dotIdentifier'], title: contentlet.dataset?.['dotTitle'], inode: contentlet.dataset?.['dotInode'], contentType: contentlet.dataset?.['dotType'] } }) }; }); } /** * Get container data from VTLS. * * @export * @param {HTMLElement} container - The container element. * @return {object} An object containing the container data. * @example * ```ts * const container = document.querySelector('.container'); * const data = getContainerData(container); * console.log(data); * ``` */ function getDotCMSContainerData(container) { return { acceptTypes: container.dataset?.['dotAcceptTypes'] || '', identifier: container.dataset?.['dotIdentifier'] || '', maxContentlets: container.dataset?.['maxContentlets'] || '', uuid: container.dataset?.['dotUuid'] || '' }; } /** * Get the closest container data from the contentlet. * * @export * @param {Element} element - The contentlet element. * @return {object | null} An object containing the closest container data or null if no container is found. * @example * ```ts * const contentlet = document.querySelector('.contentlet'); * const data = getClosestDotCMSContainerData(contentlet); * console.log(data); * ``` */ function getClosestDotCMSContainerData(element) { // Find the closest ancestor element with data-dot-object="container" attribute const container = element.closest('[data-dot-object="container"]'); // If a container element is found if (container) { // Return the dataset of the container element return getDotCMSContainerData(container); } else { // If no container element is found, return null console.warn('No container found for the contentlet'); return null; } } /** * Find the closest contentlet element based on HTMLElement. * * @export * @param {HTMLElement | null} element - The starting element. * @return {HTMLElement | null} The closest contentlet element or null if not found. * @example * const element = document.querySelector('.some-element'); * const contentlet = findDotCMSElement(element); * console.log(contentlet); */ function findDotCMSElement(element) { if (!element) return null; const emptyContent = element.querySelector('[data-dot-object="empty-content"]'); if (element?.dataset?.['dotObject'] === 'contentlet' || // The container inside Headless components have a span with the data-dot-object="container" attribute element?.dataset?.['dotObject'] === 'container' && emptyContent || // The container inside Traditional have no content inside element?.dataset?.['dotObject'] === 'container' && element.children.length === 0) { return element; } return findDotCMSElement(element?.['parentElement']); } /** * Find VTL data within a target element. * * @export * @param {HTMLElement} target - The target element to search within. * @return {Array<{ inode: string, name: string }> | null} An array of objects containing VTL data or null if none found. * @example * ```ts * const target = document.querySelector('.target-element'); * const vtlData = findDotCMSVTLData(target); * console.log(vtlData); * ``` */ function findDotCMSVTLData(target) { const vltElements = target.querySelectorAll('[data-dot-object="vtl-file"]'); if (!vltElements.length) { return null; } return Array.from(vltElements).map(vltElement => { return { inode: vltElement.dataset?.['dotInode'], name: vltElement.dataset?.['dotUrl'] }; }); } /** * Check if the scroll position is at the bottom of the page. * * @export * @return {boolean} True if the scroll position is at the bottom, otherwise false. * @example * ```ts * if (dotCMSScrollIsInBottom()) { * console.log('Scrolled to the bottom'); * } * ``` */ function computeScrollIsInBottom() { const documentHeight = document.documentElement.scrollHeight; const viewportHeight = window.innerHeight; const scrollY = window.scrollY; return scrollY + viewportHeight >= documentHeight; } /** * * * Combine classes into a single string. * * @param {string[]} classes * @returns {string} Combined classes */ const combineClasses = classes => classes.filter(Boolean).join(' '); /** * * * Calculates and returns the CSS Grid positioning classes for a column based on its configuration. * Uses a 12-column grid system where columns are positioned using grid-column-start and grid-column-end. * * @example * ```typescript * const classes = getColumnPositionClasses({ * leftOffset: 1, // Starts at the first column * width: 6 // Spans 6 columns * }); * // Returns: { startClass: 'col-start-1', endClass: 'col-end-7' } * ``` * * @param {DotPageAssetLayoutColumn} column - Column configuration object * @param {number} column.leftOffset - Starting position (0-based) in the grid * @param {number} column.width - Number of columns to span * @returns {{ startClass: string, endClass: string }} Object containing CSS class names for grid positioning */ const getColumnPositionClasses = column => { const { leftOffset, width } = column; const startClass = `${START_CLASS}${leftOffset}`; const endClass = `${END_CLASS}${leftOffset + width}`; return { startClass, endClass }; }; /** * * * Helper function that returns an object containing the dotCMS data attributes. * @param {DotCMSBasicContentlet} contentlet - The contentlet to get the attributes for * @param {string} container - The container to get the attributes for * @returns {DotContentletAttributes} The dotCMS data attributes */ function getDotContentletAttributes(contentlet, container) { return { 'data-dot-identifier': contentlet?.identifier, 'data-dot-basetype': contentlet?.baseType, 'data-dot-title': contentlet?.['widgetTitle'] || contentlet?.title, 'data-dot-inode': contentlet?.inode, 'data-dot-type': contentlet?.contentType, 'data-dot-container': container, 'data-dot-on-number-of-pages': contentlet?.['onNumberOfPages'] || '1' }; } /** * Helper function that returns an object containing analytics-specific data attributes. * These attributes are used by the DotCMS Analytics SDK to track content interactions. * * @param {DotCMSBasicContentlet} contentlet - The contentlet to get the analytics attributes for * @returns {DotAnalyticsAttributes} The analytics data attributes * @internal */ function getDotAnalyticsAttributes(contentlet) { return { 'data-dot-analytics-identifier': contentlet?.identifier, 'data-dot-analytics-inode': contentlet?.inode, 'data-dot-analytics-basetype': contentlet?.baseType, 'data-dot-analytics-contenttype': contentlet?.contentType, 'data-dot-analytics-title': contentlet?.['widgetTitle'] || contentlet?.title }; } /** * * * Retrieves container data from a DotCMS page asset using the container reference. * This function processes the container information and returns a standardized format * for container editing. * * @param {DotCMSPageAsset} dotCMSPageAsset - The page asset containing all containers data * @param {DotCMSColumnContainer} columContainer - The container reference from the layout * @throws {Error} When page asset is invalid or container is not found * @returns {EditableContainerData} Formatted container data for editing * * @example * const containerData = getContainersData(pageAsset, containerRef); * // Returns: { uuid: '123', identifier: 'cont1', acceptTypes: 'type1,type2', maxContentlets: 5 } */ const getContainersData = (dotCMSPageAsset, columContainer) => { const { identifier, uuid } = columContainer; const dotContainer = dotCMSPageAsset.containers[identifier]; if (!dotContainer) { return null; } const { containerStructures, container } = dotContainer; const acceptTypes = containerStructures?.map(structure => structure.contentTypeVar).join(',') ?? ''; const variantId = container?.parentPermissionable?.variantId; const maxContentlets = container?.maxContentlets ?? 0; const path = container?.path; return { uuid, variantId, acceptTypes, maxContentlets, identifier: path ?? identifier }; }; /** * * * Retrieves the contentlets (content items) associated with a specific container. * Handles different UUID formats and provides warning for missing contentlets. * * @param {DotCMSPageAsset} dotCMSPageAsset - The page asset containing all containers data * @param {DotCMSColumnContainer} columContainer - The container reference from the layout * @returns {DotCMSBasicContentlet[]} Array of contentlets in the container * * @example * const contentlets = getContentletsInContainer(pageAsset, containerRef); * // Returns: [{ identifier: 'cont1', ... }, { identifier: 'cont2', ... }] */ const getContentletsInContainer = (dotCMSPageAsset, columContainer) => { const { identifier, uuid } = columContainer; const { contentlets } = dotCMSPageAsset.containers[identifier]; const contentletsInContainer = contentlets[`uuid-${uuid}`] || contentlets[`uuid-dotParser_${uuid}`] || []; if (!contentletsInContainer) { console.warn(`We couldn't find the contentlets for the container with the identifier ${identifier} and the uuid ${uuid} becareful by adding content to this container.\nWe recommend to change the container in the layout and add the content again.`); } return contentletsInContainer; }; /** * * * Generates the required DotCMS data attributes for a container element. * These attributes are used by DotCMS for container identification and functionality. * * @param {EditableContainerData} params - Container data including uuid, identifier, acceptTypes, and maxContentlets * @returns {DotContainerAttributes} Object containing all necessary data attributes * * @example * const attributes = getDotContainerAttributes({ * uuid: '123', * identifier: 'cont1', * acceptTypes: 'type1,type2', * maxContentlets: 5 * }); * // Returns: { 'data-dot-object': 'container', 'data-dot-identifier': 'cont1', ... } */ function getDotContainerAttributes({ uuid, identifier, acceptTypes, maxContentlets }) { return { 'data-dot-object': 'container', 'data-dot-accept-types': acceptTypes, 'data-dot-identifier': identifier, 'data-max-contentlets': maxContentlets.toString(), 'data-dot-uuid': uuid }; } /** * Subscribes to content changes in the UVE editor * * @param {UVEEventHandler} callback - Function to be called when content changes are detected * @returns {Object} Object containing unsubscribe function and event type * @returns {Function} .unsubscribe - Function to remove the event listener * @returns {UVEEventType} .event - The event type being subscribed to * @internal */ function onContentChanges(callback) { const messageCallback = event => { if (event.data.name === __DOTCMS_UVE_EVENT__.UVE_SET_PAGE_DATA) { callback(event.data.payload); } }; window.addEventListener('message', messageCallback); return { unsubscribe: () => { window.removeEventListener('message', messageCallback); }, event: UVEEventType.CONTENT_CHANGES }; } /** * Subscribes to page reload events in the UVE editor * * @param {UVEEventHandler} callback - Function to be called when page reload is triggered * @returns {Object} Object containing unsubscribe function and event type * @returns {Function} .unsubscribe - Function to remove the event listener * @returns {UVEEventType} .event - The event type being subscribed to * @internal */ function onPageReload(callback) { const messageCallback = event => { if (event.data.name === __DOTCMS_UVE_EVENT__.UVE_RELOAD_PAGE) { callback(); } }; window.addEventListener('message', messageCallback); return { unsubscribe: () => { window.removeEventListener('message', messageCallback); }, event: UVEEventType.PAGE_RELOAD }; } /** * Subscribes to request bounds events in the UVE editor * * @param {UVEEventHandler} callback - Function to be called when bounds are requested * @returns {Object} Object containing unsubscribe function and event type * @returns {Function} .unsubscribe - Function to remove the event listener * @returns {UVEEventType} .event - The event type being subscribed to * @internal */ function onRequestBounds(callback) { const messageCallback = event => { if (event.data.name === __DOTCMS_UVE_EVENT__.UVE_REQUEST_BOUNDS) { const containers = Array.from(document.querySelectorAll('[data-dot-object="container"]')); const positionData = getDotCMSPageBounds(containers); callback(positionData); } }; window.addEventListener('message', messageCallback); return { unsubscribe: () => { window.removeEventListener('message', messageCallback); }, event: UVEEventType.REQUEST_BOUNDS }; } /** * Subscribes to iframe scroll events in the UVE editor * * @param {UVEEventHandler} callback - Function to be called when iframe scroll occurs * @returns {Object} Object containing unsubscribe function and event type * @returns {Function} .unsubscribe - Function to remove the event listener * @returns {UVEEventType} .event - The event type being subscribed to * @internal */ function onIframeScroll(callback) { const messageCallback = event => { if (event.data.name === __DOTCMS_UVE_EVENT__.UVE_SCROLL_INSIDE_IFRAME) { const direction = event.data.direction; callback(direction); } }; window.addEventListener('message', messageCallback); return { unsubscribe: () => { window.removeEventListener('message', messageCallback); }, event: UVEEventType.IFRAME_SCROLL }; } /** * Subscribes to contentlet hover events in the UVE editor * * @param {UVEEventHandler} callback - Function to be called when a contentlet is hovered * @returns {Object} Object containing unsubscribe function and event type * @returns {Function} .unsubscribe - Function to remove the event listener * @returns {UVEEventType} .event - The event type being subscribed to * @internal */ function onContentletHovered(callback) { const pointerMoveCallback = event => { const foundElement = findDotCMSElement(event.target); if (!foundElement) return; const { x, y, width, height } = foundElement.getBoundingClientRect(); const isContainer = foundElement.dataset?.['dotObject'] === 'container'; const contentletForEmptyContainer = { identifier: 'TEMP_EMPTY_CONTENTLET', title: 'TEMP_EMPTY_CONTENTLET', contentType: 'TEMP_EMPTY_CONTENTLET_TYPE', inode: 'TEMPY_EMPTY_CONTENTLET_INODE', widgetTitle: 'TEMP_EMPTY_CONTENTLET', baseType: 'TEMP_EMPTY_CONTENTLET', onNumberOfPages: 1 }; const contentlet = { identifier: foundElement.dataset?.['dotIdentifier'], title: foundElement.dataset?.['dotTitle'], inode: foundElement.dataset?.['dotInode'], contentType: foundElement.dataset?.['dotType'], baseType: foundElement.dataset?.['dotBasetype'], widgetTitle: foundElement.dataset?.['dotWidgetTitle'], onNumberOfPages: foundElement.dataset?.['dotOnNumberOfPages'] }; const vtlFiles = findDotCMSVTLData(foundElement); const contentletPayload = { container: // Here extract dot-container from contentlet if it is Headless // or search in parent container if it is VTL foundElement.dataset?.['dotContainer'] ? JSON.parse(foundElement.dataset?.['dotContainer']) : getClosestDotCMSContainerData(foundElement), contentlet: isContainer ? contentletForEmptyContainer : contentlet, vtlFiles }; const contentletHoveredPayload = { x, y, width, height, payload: contentletPayload }; callback(contentletHoveredPayload); }; document.addEventListener('pointermove', pointerMoveCallback); return { unsubscribe: () => { document.removeEventListener('pointermove', pointerMoveCallback); }, event: UVEEventType.CONTENTLET_HOVERED }; } /** * Events that can be subscribed to in the UVE * * @internal * @type {Record<UVEEventType, UVEEventSubscriber>} */ const __UVE_EVENTS__ = { [UVEEventType.CONTENT_CHANGES]: callback => { return onContentChanges(callback); }, [UVEEventType.PAGE_RELOAD]: callback => { return onPageReload(callback); }, [UVEEventType.REQUEST_BOUNDS]: callback => { return onRequestBounds(callback); }, [UVEEventType.IFRAME_SCROLL]: callback => { return onIframeScroll(callback); }, [UVEEventType.CONTENTLET_HOVERED]: callback => { return onContentletHovered(callback); } }; /** * Default UVE event * * @param {string} event - The event to subscribe to. * @internal */ const __UVE_EVENT_ERROR_FALLBACK__ = event => { return { unsubscribe: () => { /* do nothing */ }, event }; }; /** * Development mode * * @internal */ const DEVELOPMENT_MODE = 'development'; /** * Production mode * * @internal */ const PRODUCTION_MODE = 'production'; /** * End class * * @internal */ const END_CLASS = 'col-end-'; /** * Start class * * @internal */ const START_CLASS = 'col-start-'; /** * Empty container style for React * * @internal */ const EMPTY_CONTAINER_STYLE_REACT = { width: '100%', backgroundColor: '#ECF0FD', display: 'flex', justifyContent: 'center', alignItems: 'center', color: '#030E32', height: '10rem' }; /** * Empty container style for Angular * * @internal */ const EMPTY_CONTAINER_STYLE_ANGULAR = { width: '100%', 'background-color': '#ECF0FD', display: 'flex', 'justify-content': 'center', 'align-items': 'center', color: '#030E32', height: '10rem' }; /** * Custom no component * * @internal */ const CUSTOM_NO_COMPONENT = 'CustomNoComponent'; // Analytics active flag key const ANALYTICS_WINDOWS_ACTIVE_KEY = '__dotAnalyticsActive__'; // Analytics cleanup function key const ANALYTICS_WINDOWS_CLEANUP_KEY = '__dotAnalyticsCleanup'; /** * Gets the current state of the Universal Visual Editor (UVE). * * This function checks if the code is running inside the DotCMS Universal Visual Editor * and returns information about its current state, including the editor mode. * * @export * @return {UVEState | undefined} Returns the UVE state object if running inside the editor, * undefined otherwise. * * The state includes: * - mode: The current editor mode (preview, edit, live) * - languageId: The language ID of the current page setted on the UVE * - persona: The persona of the current page setted on the UVE * - variantName: The name of the current variant * - experimentId: The ID of the current experiment * - publishDate: The publish date of the current page setted on the UVE * * @note The absence of any of these properties means that the value is the default one. * * @example * ```ts * const editorState = getUVEState(); * if (editorState?.mode === 'edit') { * // Enable editing features * } * ``` */ function getUVEState() { if (typeof window === 'undefined' || window.parent === window || !window.location) { return undefined; } const url = new URL(window.location.href); const possibleModes = Object.values(UVE_MODE); let mode = url.searchParams.get('mode') ?? UVE_MODE.EDIT; const languageId = url.searchParams.get('language_id'); const persona = url.searchParams.get('personaId'); const variantName = url.searchParams.get('variantName'); const experimentId = url.searchParams.get('experimentId'); const publishDate = url.searchParams.get('publishDate'); const dotCMSHost = url.searchParams.get('dotCMSHost'); if (!possibleModes.includes(mode)) { mode = UVE_MODE.EDIT; } return { mode, languageId, persona, variantName, experimentId, publishDate, dotCMSHost }; } /** * Creates a subscription to a UVE event. * * @param eventType - The type of event to subscribe to * @param callback - The callback function that will be called when the event occurs * @returns An event subscription that can be used to unsubscribe * * @example * ```ts * // Subscribe to page changes * const subscription = createUVESubscription(UVEEventType.CONTENT_CHANGES, (changes) => { * console.log('Content changes:', changes); * }); * * // Later, unsubscribe when no longer needed * subscription.unsubscribe(); * ``` */ function createUVESubscription(eventType, callback) { if (!getUVEState()) { console.warn('UVE Subscription: Not running inside UVE'); return __UVE_EVENT_ERROR_FALLBACK__(eventType); } const eventCallback = __UVE_EVENTS__[eventType]; if (!eventCallback) { console.error(`UVE Subscription: Event ${eventType} not found`); return __UVE_EVENT_ERROR_FALLBACK__(eventType); } return eventCallback(callback); } /** * Checks if DotCMS Analytics is active by verifying the global window flag. * * This function checks for the presence of the `__dotAnalyticsActive__` flag on the window object, * which is set by the `@dotcms/analytics` SDK when Analytics is successfully initialized. * * This utility can be used in any JavaScript framework (React, Angular, Vue, etc.) to conditionally * enable analytics-related features or data attributes. * * @export * @returns {boolean} true if Analytics is initialized and active, false otherwise * * @example * ```ts * // React example * import { isAnalyticsActive } from '@dotcms/uve/internal'; * * function MyComponent() { * const shouldTrack = isAnalyticsActive(); * * if (shouldTrack) { * // Add analytics tracking * } * } * ``` * * @example * ```ts * // Angular example * import { isAnalyticsActive } from '@dotcms/uve/internal'; * * if (isAnalyticsActive()) { * // Apply analytics attributes to elements * element.setAttribute('data-dot-object', 'contentlet'); * } * ``` * * @example * ```ts * // Vanilla JavaScript / Any framework * import { isAnalyticsActive } from '@dotcms/uve/internal'; * * if (isAnalyticsActive()) { * console.log('DotCMS Analytics is active'); * } * ``` */ function isAnalyticsActive() { // eslint-disable-next-line @typescript-eslint/no-explicit-any return typeof window !== 'undefined' && window[ANALYTICS_WINDOWS_ACTIVE_KEY] === true; } /** * Sets the bounds of the containers in the editor. * Retrieves the containers from the DOM and sends their position data to the editor. * @private * @memberof DotCMSPageEditor */ function setBounds(bounds) { sendMessageToUVE({ action: DotCMSUVEAction.SET_BOUNDS, payload: bounds }); } /** * Validates the structure of a Block Editor node. * * This function performs validation checks on a BlockEditorNode object to ensure: * - The node exists and is a valid object * - The node has a 'doc' type * - The node has a valid content array containing at least one block * - Each block in the content array: * - Has a valid string type property * - Has valid object attributes (if present) * - Has valid nested content (if present) * * @param {BlockEditorNode} blocks - The BlockEditorNode structure to validate * @returns {BlockEditorState} The validation result * @property {string | null} BlockEditorState.error - Error message if validation fails, null if valid */ const isValidBlocks = blocks => { if (!blocks) { return { error: `Error: Blocks object is not defined` }; } if (typeof blocks !== 'object') { return { error: `Error: Blocks must be an object, but received: ${typeof blocks}` }; } if (blocks.type !== 'doc') { return { error: `Error: Invalid block type. Expected 'doc' but received: '${blocks.type}'` }; } if (!blocks.content) { return { error: 'Error: Blocks content is missing' }; } if (!Array.isArray(blocks.content)) { return { error: `Error: Blocks content must be an array, but received: ${typeof blocks.content}` }; } if (blocks.content.length === 0) { return { error: 'Error: Blocks content is empty. At least one block is required.' }; } // Validate each block in the content array for (let i = 0; i < blocks.content.length; i++) { const block = blocks.content[i]; if (!block.type) { return { error: `Error: Block at index ${i} is missing required 'type' property` }; } if (typeof block.type !== 'string') { return { error: `Error: Block type at index ${i} must be a string, but received: ${typeof block.type}` }; } // Validate block attributes if present if (block.attrs && typeof block.attrs !== 'object') { return { error: `Error: Block attributes at index ${i} must be an object, but received: ${typeof block.attrs}` }; } // Validate nested content if present if (block.content) { if (!Array.isArray(block.content)) { return { error: `Error: Block content at index ${i} must be an array, but received: ${typeof block.content}` }; } // Recursively validate nested blocks const nestedValidation = isValidBlocks({ type: 'doc', content: block.content }); if (nestedValidation.error) { return { error: `Error in nested block at index ${i}: ${nestedValidation.error}` }; } } } return { error: null }; }; /* eslint-disable @typescript-eslint/no-explicit-any */ /** * Sets up scroll event handlers for the window to notify the editor about scroll events. * Adds listeners for both 'scroll' and 'scrollend' events, sending appropriate messages * to the editor when these events occur. */ function scrollHandler() { const scrollCallback = () => { sendMessageToUVE({ action: DotCMSUVEAction.IFRAME_SCROLL }); }; const scrollEndCallback = () => { sendMessageToUVE({ action: DotCMSUVEAction.IFRAME_SCROLL_END }); }; window.addEventListener('scroll', scrollCallback); window.addEventListener('scrollend', scrollEndCallback); return { destroyScrollHandler: () => { window.removeEventListener('scroll', scrollCallback); window.removeEventListener('scrollend', scrollEndCallback); } }; } /** * Adds 'empty-contentlet' class to contentlet elements that have no height. * This helps identify and style empty contentlets in the editor view. * * @remarks * The function queries all elements with data-dot-object="contentlet" attribute * and checks their clientHeight. If an element has no height (clientHeight = 0), * it adds the 'empty-contentlet' class to that element. */ function addClassToEmptyContentlets() { const contentlets = document.querySelectorAll('[data-dot-object="contentlet"]'); contentlets.forEach(contentlet => { if (contentlet.clientHeight) { return; } contentlet.classList.add('empty-contentlet'); }); } /** * Registers event handlers for various UVE (Universal Visual Editor) events. * * This function sets up subscriptions for: * - Page reload events that refresh the window * - Bounds request events to update editor boundaries * - Iframe scroll events to handle smooth scrolling within bounds * - Contentlet hover events to notify the editor * * @remarks * For scroll events, the function includes logic to prevent scrolling beyond * the top or bottom boundaries of the iframe, which helps maintain proper * scroll event handling. */ function registerUVEEvents() { const pageReloadSubscription = createUVESubscription(UVEEventType.PAGE_RELOAD, () => { window.location.reload(); }); const requestBoundsSubscription = createUVESubscription(UVEEventType.REQUEST_BOUNDS, bounds => { setBounds(bounds); }); const iframeScrollSubscription = createUVESubscription(UVEEventType.IFRAME_SCROLL, direction => { if (window.scrollY === 0 && direction === 'up' || computeScrollIsInBottom() && direction === 'down') { // If the iframe scroll is at the top or bottom, do not send anything. // This avoids losing the scrollend event. return; } const scrollY = direction === 'up' ? -120 : 120; window.scrollBy({ left: 0, top: scrollY, behavior: 'smooth' }); }); const contentletHoveredSubscription = createUVESubscription(UVEEventType.CONTENTLET_HOVERED, contentletHovered => { sendMessageToUVE({ action: DotCMSUVEAction.SET_CONTENTLET, payload: contentletHovered }); }); return { subscriptions: [pageReloadSubscription, requestBoundsSubscription, iframeScrollSubscription, contentletHoveredSubscription] }; } /** * Notifies the editor that the UVE client is ready to receive messages. * * This function sends a message to the editor indicating that the client-side * initialization is complete and it's ready to handle editor interactions. * * @remarks * This is typically called after all UVE event handlers and DOM listeners * have been set up successfully. */ function setClientIsReady(config) { sendMessageToUVE({ action: DotCMSUVEAction.CLIENT_READY, payload: config }); } /** * Listen for block editor inline event. */ function listenBlockEditorInlineEvent() { if (document.readyState === 'complete') { // The page is fully loaded or interactive listenBlockEditorClick(); return { destroyListenBlockEditorInlineEvent: () => { window.removeEventListener('load', () => listenBlockEditorClick()); } }; } window.addEventListener('load', () => listenBlockEditorClick()); return { destroyListenBlockEditorInlineEvent: () => { window.removeEventListener('load', () => listenBlockEditorClick()); } }; } const listenBlockEditorClick = () => { const editBlockEditorNodes = document.querySelectorAll('[data-block-editor-content]'); if (!editBlockEditorNodes.length) { return; } editBlockEditorNodes.forEach(node => { const { inode, language = '1', contentType, fieldName, blockEditorContent } = node.dataset; const content = JSON.parse(blockEditorContent || ''); if (!inode || !language || !contentType || !fieldName) { console.error('Missing data attributes for block editor inline editing.'); console.warn('inode, language, contentType and fieldName are required.'); return; } node.classList.add('dotcms__inline-edit-field'); node.addEventListener('click', () => { initInlineEditing('BLOCK_EDITOR', { inode, content, language: parseInt(language), fieldName, contentType }); }); }); }; /** * Updates the navigation in the editor. * * @param {string} pathname - The pathname to update the navigation with. * @memberof DotCMSPageEditor * @example * updateNavigation('/home'); // Sends a message to the editor to update the navigation to '/home' */ function updateNavigation(pathname) { sendMessageToUVE({ action: DotCMSUVEAction.NAVIGATION_UPDATE, payload: { url: pathname || '/' } }); } /** * Post message to dotcms page editor * * @export * @template T * @param {DotCMSUVEMessage<T>} message */ function sendMessageToUVE(message) { window.parent.postMessage(message, '*'); } /** * You can use this function to edit a contentlet in the editor. * * Calling this function inside the editor, will prompt the UVE to open a dialog to edit the contentlet. * * @export * @template T * @param {Contentlet<T>} contentlet - The contentlet to edit. */ function editContentlet(contentlet) { sendMessageToUVE({ action: DotCMSUVEAction.EDIT_CONTENTLET, payload: contentlet }); } /* * Reorders the menu based on the provided configuration. * * @param {ReorderMenuConfig} [config] - Optional configuration for reordering the menu. * @param {number} [config.startLevel=1] - The starting level of the menu to reorder. * @param {number} [config.depth=2] - The depth of the menu to reorder. * * This function constructs a URL for the reorder menu page with the specified * start level and depth, and sends a message to the editor to perform the reorder action. */ function reorderMenu(config) { const { startLevel = 1, depth = 2 } = config || {}; sendMessageToUVE({ action: DotCMSUVEAction.REORDER_MENU, payload: { startLevel, depth } }); } /** * Initializes the inline editing in the editor. * * @export * @param {INLINE_EDITING_EVENT_KEY} type * @param {InlineEditEventData} eventData * @return {*} * * * @example * ```html * <div onclick="initInlineEditing('BLOCK_EDITOR', { inode, languageId, contentType, fieldName, content })"> * ${My Content} * </div> * ``` */ function initInlineEditing(type, data) { sendMessageToUVE({ action: DotCMSUVEAction.INIT_INLINE_EDITING, payload: { type, data } }); } /** * Initializes the block editor inline editing for a contentlet field. * * @example * ```html * <div onclick="enableBlockEditorInline(contentlet, 'MY_BLOCK_EDITOR_FIELD_VARIABLE')"> * ${My Content} * </div> * ``` * * @export * @param {DotCMSBasicContentlet} contentlet * @param {string} fieldName * @return {*} {void} */ function enableBlockEditorInline(contentlet, fieldName) { if (!contentlet?.[fieldName]) { console.error(`Contentlet ${contentlet?.identifier} does not have field ${fieldName}`); return; } const data = { fieldName: fieldName, inode: contentlet.inode, language: contentlet.languageId, contentType: contentlet.contentType, content: contentlet[fieldName] }; initInlineEditing('BLOCK_EDITOR', data); } /** * Initializes the Universal Visual Editor (UVE) with required handlers and event listeners. * * This function sets up: * - Scroll handling * - Empty contentlet styling * - Block editor inline event listening * - Client ready state * - UVE event subscriptions * * @returns {Object} An object containing the cleanup function * @returns {Function} destroyUVESubscriptions - Function to clean up all UVE event subscriptions * * @example * ```typescript * const { destroyUVESubscriptions } = initUVE(); * * // When done with UVE * destroyUVESubscriptions(); * ``` */ function initUVE(config = {}) { addClassToEmptyContentlets(); setClientIsReady(config); const { subscriptions } = registerUVEEvents(); const { destroyScrollHandler } = scrollHandler(); const { destroyListenBlockEditorInlineEvent } = listenBlockEditorInlineEvent(); return { destroyUVESubscriptions: () => { subscriptions.forEach(subscription => subscription.unsubscribe()); destroyScrollHandler(); destroyListenBlockEditorInlineEvent(); } }; } export { ANALYTICS_WINDOWS_ACTIVE_KEY as A, getContentletsInContainer as B, CUSTOM_NO_COMPONENT as C, DEVELOPMENT_MODE as D, END_CLASS as E, getDotContainerAttributes as F, setBounds as G, isValidBlocks as H, PRODUCTION_MODE as P, START_CLASS as S, __UVE_EVENTS__ as _, initInlineEditing as a, enableBlockEditorInline as b, createUVESubscription as c, initUVE as d, editContentlet as e, __UVE_EVENT_ERROR_FALLBACK__ as f, getUVEState as g, EMPTY_CONTAINER_STYLE_REACT as h, isAnalyticsActive as i, EMPTY_CONTAINER_STYLE_ANGULAR as j, ANALYTICS_WINDOWS_CLEANUP_KEY as k, getDotCMSPageBounds as l, getDotCMSContentletsBound as m, getDotCMSContainerData as n, getClosestDotCMSContainerData as o, findDotCMSElement as p, findDotCMSVTLData as q, reorderMenu as r, sendMessageToUVE as s, computeScrollIsInBottom as t, updateNavigation as u, combineClasses as v, getColumnPositionClasses as w, getDotContentletAttributes as x, getDotAnalyticsAttributes as y, getContainersData as z };