UNPKG

@dotcms/uve

Version:

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

1,475 lines (1,464 loc) 49.5 kB
'use strict'; var types = require('@dotcms/types'); var internal = require('@dotcms/types/internal'); /** * Sentinel values for the placeholder contentlet used when the UVE represents * an empty container (e.g. hover / selection without a real contentlet). * * @internal */ const TEMP_EMPTY_CONTENTLET = 'TEMP_EMPTY_CONTENTLET'; /** * Placeholder `contentType` for {@link TEMP_EMPTY_CONTENTLET}. * * @internal */ const TEMP_EMPTY_CONTENTLET_TYPE = 'TEMP_EMPTY_CONTENTLET_TYPE'; /** * 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', ...(contentlet?.dotStyleProperties && { 'data-dot-style-properties': JSON.stringify(contentlet.dotStyleProperties) }) }; } /** * * * 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 maxContentlets = container?.maxContentlets ?? 0; const path = container?.path; return { uuid, 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 }; } /** * Read a contentlet's dataset attributes off a DOM element and return a * normalized contentlet object. Mirrors the shape consumed by the editor's * SET_BOUNDS and CONTENTLET_CLICKED events. Optionally parses the * `dotStyleProperties` JSON when present. */ function readContentletDataset(element) { const dataset = element.dataset ?? {}; return { identifier: dataset['dotIdentifier'], title: dataset['dotTitle'], inode: dataset['dotInode'], contentType: dataset['dotType'], baseType: dataset['dotBasetype'], widgetTitle: dataset['dotWidgetTitle'], onNumberOfPages: dataset['dotOnNumberOfPages'], ...(dataset['dotStyleProperties'] && { dotStyleProperties: JSON.parse(dataset['dotStyleProperties']) }) }; } /** * 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 === internal.__DOTCMS_UVE_EVENT__.UVE_SET_PAGE_DATA) { callback(event.data.payload); } }; window.addEventListener('message', messageCallback); return { unsubscribe: () => { window.removeEventListener('message', messageCallback); }, event: types.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 === internal.__DOTCMS_UVE_EVENT__.UVE_RELOAD_PAGE) { callback(); } }; window.addEventListener('message', messageCallback); return { unsubscribe: () => { window.removeEventListener('message', messageCallback); }, event: types.UVEEventType.PAGE_RELOAD }; } const AUTO_BOUNDS_DEBOUNCE_MS = 100; /** * The single bounds-sync channel. Observes the iframe document and * every `[data-dot-object="container"]` with a single ResizeObserver, * debounces the trailing edge by {@link AUTO_BOUNDS_DEBOUNCE_MS}ms, and * emits the full `getDotCMSPageBounds(...)` payload whenever the layout * settles. Also listens on `scroll` (since scrolling moves contentlets * without changing layout) and on `UVE_FLUSH_BOUNDS` (the editor's * "give me bounds NOW, skip the debounce" message used during drag). * * Re-runs `querySelectorAll` and the observer wiring whenever a * MutationObserver detects child changes that touch container nodes, * so containers that mount/unmount after page-load are picked up * automatically. * * @internal */ function onAutoBounds(callback) { let debounceTimer = null; let observed = []; const emit = () => { const containers = Array.from(document.querySelectorAll('[data-dot-object="container"]')); callback(getDotCMSPageBounds(containers)); }; const scheduleEmit = () => { if (debounceTimer !== null) { clearTimeout(debounceTimer); } debounceTimer = setTimeout(() => { debounceTimer = null; emit(); }, AUTO_BOUNDS_DEBOUNCE_MS); }; const resizeObserver = new ResizeObserver(() => { scheduleEmit(); }); const observeAll = () => { // Tear down previous observations before re-wiring. for (const el of observed) { resizeObserver.unobserve(el); } observed = Array.from(document.querySelectorAll('[data-dot-object="container"]')); resizeObserver.observe(document.documentElement); for (const container of observed) { resizeObserver.observe(container); } }; observeAll(); // Containers can mount/unmount after the page first paints (route // changes in headless apps, lazy-loaded sections, etc.). Re-wire only // when a node carrying [data-dot-object="container"] is added or // removed — ignoring text/attribute churn keeps this observer cheap on // busy pages. const containsContainerNode = nodes => { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node.nodeType !== Node.ELEMENT_NODE) { continue; } const el = node; if (el.matches?.('[data-dot-object="container"]') || el.querySelector?.('[data-dot-object="container"]')) { return true; } } return false; }; const mutationObserver = new MutationObserver(mutations => { for (const m of mutations) { if (m.type !== 'childList') continue; if (containsContainerNode(m.addedNodes) || containsContainerNode(m.removedNodes)) { observeAll(); scheduleEmit(); return; } } }); // The SDK script can run from <head> before <body> exists. Fall back to // <html> in that case — childList+subtree on the documentElement still // catches container nodes that mount once <body> arrives. mutationObserver.observe(document.body ?? document.documentElement, { childList: true, subtree: true }); // Scrolling inside the iframe doesn't change layout, so ResizeObserver // doesn't fire, but every contentlet's viewport-relative position // (getBoundingClientRect) does change. Re-emit bounds after each // scroll burst settles so the editor's pinned selected overlay // re-anchors to the on-screen position. const onScroll = () => scheduleEmit(); window.addEventListener('scroll', onScroll, { passive: true }); // Flush channel: the editor occasionally needs an immediate snapshot // of bounds (drag enter, where the dropzone has to know container // rectangles before the user moves another pixel). Bypass the // debounce timer and emit synchronously. const onFlush = event => { if (event?.data?.name !== internal.__DOTCMS_UVE_EVENT__.UVE_FLUSH_BOUNDS) return; if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; } emit(); }; window.addEventListener('message', onFlush); return { unsubscribe: () => { if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; } resizeObserver.disconnect(); mutationObserver.disconnect(); window.removeEventListener('scroll', onScroll); window.removeEventListener('message', onFlush); observed = []; }, event: types.UVEEventType.AUTO_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 === internal.__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: types.UVEEventType.IFRAME_SCROLL }; } /** * Listens for scroll-to-section requests from the UVE editor. * * Queries `#dot-section-{n}` first, then falls back to `#section-{n}`. * If the element is found, calls the callback with `{ sectionIndex, offsetTop }`. * If not found, the callback is not invoked. * * @param {UVEEventHandler} callback - Receives `{ sectionIndex: number; offsetTop: number }`. * @internal */ function onScrollToSection(callback) { const messageCallback = event => { if (event.data.name !== internal.__DOTCMS_UVE_EVENT__.UVE_SCROLL_TO_SECTION) { return; } const sectionIndex = event.data.sectionIndex; const el = document.querySelector(`#${DOT_SECTION_ID_PREFIX}${sectionIndex}`) ?? document.querySelector(`#section-${sectionIndex}`); if (!el) { return; } callback({ sectionIndex, offsetTop: el.offsetTop }); }; window.addEventListener('message', messageCallback); return { unsubscribe: () => { window.removeEventListener('message', messageCallback); }, event: types.UVEEventType.SCROLL_TO_SECTION }; } /** * Subscribes to contentlet hover events in the UVE editor. * * The callback is invoked with a payload while the pointer is over a * DotCMS element, and once with `null` when the pointer leaves the last * reported element (transitions onto dead space). The editor uses the * `null` signal to clear the hover overlay so it doesn't linger over * areas that no longer have a contentlet under the pointer. * * @param {UVEEventHandler} callback - Function to be called when hover state changes * @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) { let hasHover = false; const pointerMoveCallback = event => { const foundElement = findDotCMSElement(event.target); if (!foundElement) { // Transitioning from a hovered contentlet to dead space — emit // a single null so the editor can clear its hover overlay. // Subsequent moves over dead space are no-ops. if (hasHover) { hasHover = false; callback(null); } 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 = readContentletDataset(foundElement); 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 }; hasHover = true; callback(contentletHoveredPayload); }; // We intentionally do not fire null on document `pointerleave`: the // editor's hover toolbar lives in the parent window (outside the // iframe), so leaving the iframe usually means the user is heading // for the toolbar. Killing the overlay there would yank the toolbar // away just as the user reaches for it. Dead-space-inside-iframe // is already covered by the `pointermove` null branch above. document.addEventListener('pointermove', pointerMoveCallback); return { unsubscribe: () => { document.removeEventListener('pointermove', pointerMoveCallback); }, event: types.UVEEventType.CONTENTLET_HOVERED }; } /** * Subscribes to contentlet click events in the UVE editor. * * The editor's hover overlay is `pointer-events: none` so wheel events pass * through to the iframe. We detect the user's selection click here instead and * post it back to the editor. * * @param {UVEEventHandler} callback - Function to be called when a contentlet is clicked * @returns {Object} Object containing unsubscribe function and event type * @internal */ function onContentletClicked(callback) { // Track the last selected contentlet so a second click on the same one // lets the page's native click through (links, accordions, etc.). The // first click is "select"; subsequent clicks on the selected contentlet // are "interact with the page". let lastSelectedInode; const clickCallback = event => { const foundElement = findDotCMSElement(event.target); if (!foundElement) return; const isContainer = foundElement.dataset?.['dotObject'] === 'container'; // Only emit for contentlet clicks; an empty container click is a no-op // for selection purposes (there's nothing to select). if (isContainer) return; const inode = foundElement.dataset?.['dotInode']; // If the user is clicking the already-selected contentlet, let the // page handle the click natively (link navigation, button handlers, // form submission). The editor selection toolbar already exposes the // edit/delete/etc actions; the contentlet's own UI should still work. if (inode && inode === lastSelectedInode) { return; } // First click on this contentlet (or a different one) — select it in // the editor and block the page's natural click. Capture phase + // preventDefault + stopPropagation suppresses both the default action // and any subscribers further down the tree. event.preventDefault(); event.stopPropagation(); lastSelectedInode = inode; const { x, y, width, height } = foundElement.getBoundingClientRect(); const contentlet = readContentletDataset(foundElement); const vtlFiles = findDotCMSVTLData(foundElement); callback({ x, y, width, height, payload: { container: foundElement.dataset?.['dotContainer'] ? JSON.parse(foundElement.dataset?.['dotContainer']) : getClosestDotCMSContainerData(foundElement), contentlet, vtlFiles } }); }; // The editor clears its selection on canvas resize / scroll. When that // happens, our lastSelectedInode is stale: a click on what used to be the // selected contentlet would be treated as a passthrough (page click) even // though the editor no longer has it selected. Listen for the // UVE_SELECTION_CLEARED message and reset the tracker. const selectionClearedCallback = event => { if (event?.data?.name === internal.__DOTCMS_UVE_EVENT__.UVE_SELECTION_CLEARED) { lastSelectedInode = undefined; } }; // Capture phase so we run BEFORE the page's own click handlers and can // preventDefault/stopPropagation effectively. document.addEventListener('click', clickCallback, { capture: true }); window.addEventListener('message', selectionClearedCallback); return { unsubscribe: () => { document.removeEventListener('click', clickCallback, { capture: true }); window.removeEventListener('message', selectionClearedCallback); }, event: types.UVEEventType.CONTENTLET_CLICKED }; } /** * Events that can be subscribed to in the UVE * * @internal * @type {Record<UVEEventType, UVEEventSubscriber>} */ const __UVE_EVENTS__ = { [types.UVEEventType.CONTENT_CHANGES]: callback => { return onContentChanges(callback); }, [types.UVEEventType.PAGE_RELOAD]: callback => { return onPageReload(callback); }, [types.UVEEventType.IFRAME_SCROLL]: callback => { return onIframeScroll(callback); }, [types.UVEEventType.CONTENTLET_HOVERED]: callback => { return onContentletHovered(callback); }, [types.UVEEventType.CONTENTLET_CLICKED]: callback => { return onContentletClicked(callback); }, [types.UVEEventType.SCROLL_TO_SECTION]: callback => { return onScrollToSection(callback); }, // SELECTION_CLEARED is editor→SDK only. No public subscriber surface; // onContentletClicked listens for the underlying postMessage internally // to reset its lastSelectedInode tracker. [types.UVEEventType.SELECTION_CLEARED]: _callback => { return { unsubscribe: () => { /* no-op: SELECTION_CLEARED has no consumer-facing subscription */ }, event: types.UVEEventType.SELECTION_CLEARED }; }, [types.UVEEventType.AUTO_BOUNDS]: callback => { return onAutoBounds(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'; /** * ID prefix applied to page section wrappers for editor scroll-to-section support. * Used by SDK row components and the UVE scroll event handler. * * @internal */ const DOT_SECTION_ID_PREFIX = 'dot-section-'; /** * 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(types.UVE_MODE); let mode = url.searchParams.get('mode') ?? types.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 = types.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); } /** * 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: types.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: types.DotCMSUVEAction.IFRAME_SCROLL }); }; const scrollEndCallback = () => { sendMessageToUVE({ action: types.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(types.UVEEventType.PAGE_RELOAD, () => { window.location.reload(); }); const iframeScrollSubscription = createUVESubscription(types.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(types.UVEEventType.CONTENTLET_HOVERED, contentletHovered => { sendMessageToUVE({ action: types.DotCMSUVEAction.SET_CONTENTLET, payload: contentletHovered }); }); const contentletClickedSubscription = createUVESubscription(types.UVEEventType.CONTENTLET_CLICKED, contentletClicked => { sendMessageToUVE({ action: types.DotCMSUVEAction.SET_SELECTED_CONTENTLET, payload: contentletClicked }); }); const scrollToSectionSubscription = createUVESubscription(types.UVEEventType.SCROLL_TO_SECTION, payload => { sendMessageToUVE({ action: types.DotCMSUVEAction.SECTION_OFFSET, payload }); }); // The single bounds-sync channel. The SDK observes layout changes // inside the iframe (media-query reflows, image/font load shifts, // container mount/unmount, scroll, etc.) and emits SET_BOUNDS on the // trailing edge of a debounce window. The editor can also send a // UVE_FLUSH_BOUNDS message to request an immediate synchronous emit // (used during drag/drop, where the dropzone needs current bounds // before the user moves another pixel). const autoBoundsSubscription = createUVESubscription(types.UVEEventType.AUTO_BOUNDS, bounds => { setBounds(bounds); }); return { subscriptions: [pageReloadSubscription, iframeScrollSubscription, contentletHoveredSubscription, contentletClickedSubscription, scrollToSectionSubscription, autoBoundsSubscription] }; } /** * 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: types.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: () => { // No need to remove listener if page was already loaded } }; } // If the page is not fully loaded, listen for the DOMContentLoaded event const handleDOMContentLoaded = () => { listenBlockEditorClick(); }; document.addEventListener('DOMContentLoaded', handleDOMContentLoaded); return { destroyListenBlockEditorInlineEvent: () => { document.removeEventListener('DOMContentLoaded', handleDOMContentLoaded); } }; } 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: types.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: types.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: types.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: types.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); } /** * Opens the contentlet creation panel for the given content type without adding it to the page. * * This function is intended for use cases where you want to create a contentlet in the system * (e.g., for widgets that auto-pull content) without dropping it onto the current page layout. * * @export * @param {string} contentType - The content type variable or structure inode to create a contentlet for. * * @example * ```js * // Opens the creation panel for the 'Event' content type * createContentlet('Event'); * ``` */ function createContentlet(contentType) { sendMessageToUVE({ action: types.DotCMSUVEAction.CREATE_CONTENTLET, payload: { contentType } }); } /** * 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(); } }; } exports.CUSTOM_NO_COMPONENT = CUSTOM_NO_COMPONENT; exports.DEVELOPMENT_MODE = DEVELOPMENT_MODE; exports.DOT_SECTION_ID_PREFIX = DOT_SECTION_ID_PREFIX; exports.EMPTY_CONTAINER_STYLE_ANGULAR = EMPTY_CONTAINER_STYLE_ANGULAR; exports.EMPTY_CONTAINER_STYLE_REACT = EMPTY_CONTAINER_STYLE_REACT; exports.END_CLASS = END_CLASS; exports.PRODUCTION_MODE = PRODUCTION_MODE; exports.START_CLASS = START_CLASS; exports.TEMP_EMPTY_CONTENTLET = TEMP_EMPTY_CONTENTLET; exports.TEMP_EMPTY_CONTENTLET_TYPE = TEMP_EMPTY_CONTENTLET_TYPE; exports.__UVE_EVENTS__ = __UVE_EVENTS__; exports.__UVE_EVENT_ERROR_FALLBACK__ = __UVE_EVENT_ERROR_FALLBACK__; exports.combineClasses = combineClasses; exports.computeScrollIsInBottom = computeScrollIsInBottom; exports.createContentlet = createContentlet; exports.createUVESubscription = createUVESubscription; exports.editContentlet = editContentlet; exports.enableBlockEditorInline = enableBlockEditorInline; exports.findDotCMSElement = findDotCMSElement; exports.findDotCMSVTLData = findDotCMSVTLData; exports.getClosestDotCMSContainerData = getClosestDotCMSContainerData; exports.getColumnPositionClasses = getColumnPositionClasses; exports.getContainersData = getContainersData; exports.getContentletsInContainer = getContentletsInContainer; exports.getDotCMSContainerData = getDotCMSContainerData; exports.getDotCMSContentletsBound = getDotCMSContentletsBound; exports.getDotCMSPageBounds = getDotCMSPageBounds; exports.getDotContainerAttributes = getDotContainerAttributes; exports.getDotContentletAttributes = getDotContentletAttributes; exports.getUVEState = getUVEState; exports.initInlineEditing = initInlineEditing; exports.initUVE = initUVE; exports.isValidBlocks = isValidBlocks; exports.readContentletDataset = readContentletDataset; exports.reorderMenu = reorderMenu; exports.sendMessageToUVE = sendMessageToUVE; exports.setBounds = setBounds; exports.updateNavigation = updateNavigation;