@dotcms/uve
Version:
Official JavaScript library for interacting with Universal Visual Editor (UVE)
1,436 lines (1,426 loc) • 48.3 kB
JavaScript
import { UVEEventType, UVE_MODE, DotCMSUVEAction } from '@dotcms/types';
import { __DOTCMS_UVE_EVENT__ } from '@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 === __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
};
}
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 !== __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: 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 === __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
};
}
/**
* 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 !== __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: 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: 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 === __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: UVEEventType.CONTENTLET_CLICKED
};
}
/**
* 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.IFRAME_SCROLL]: callback => {
return onIframeScroll(callback);
},
[UVEEventType.CONTENTLET_HOVERED]: callback => {
return onContentletHovered(callback);
},
[UVEEventType.CONTENTLET_CLICKED]: callback => {
return onContentletClicked(callback);
},
[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.
[UVEEventType.SELECTION_CLEARED]: _callback => {
return {
unsubscribe: () => {
/* no-op: SELECTION_CLEARED has no consumer-facing subscription */
},
event: UVEEventType.SELECTION_CLEARED
};
},
[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(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);
}
/**
* 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 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
});
});
const contentletClickedSubscription = createUVESubscription(UVEEventType.CONTENTLET_CLICKED, contentletClicked => {
sendMessageToUVE({
action: DotCMSUVEAction.SET_SELECTED_CONTENTLET,
payload: contentletClicked
});
});
const scrollToSectionSubscription = createUVESubscription(UVEEventType.SCROLL_TO_SECTION, payload => {
sendMessageToUVE({
action: 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(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: 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: 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);
}
/**
* 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: 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();
}
};
}
export { getDotContainerAttributes as A, getDotContentletAttributes as B, CUSTOM_NO_COMPONENT as C, DEVELOPMENT_MODE as D, EMPTY_CONTAINER_STYLE_ANGULAR as E, isValidBlocks as F, readContentletDataset as G, setBounds as H, PRODUCTION_MODE as P, START_CLASS as S, TEMP_EMPTY_CONTENTLET as T, __UVE_EVENTS__ as _, createUVESubscription as a, enableBlockEditorInline as b, createContentlet as c, initUVE as d, editContentlet as e, DOT_SECTION_ID_PREFIX as f, getUVEState as g, EMPTY_CONTAINER_STYLE_REACT as h, initInlineEditing as i, END_CLASS as j, TEMP_EMPTY_CONTENTLET_TYPE as k, __UVE_EVENT_ERROR_FALLBACK__ as l, combineClasses as m, computeScrollIsInBottom as n, findDotCMSElement as o, findDotCMSVTLData as p, getClosestDotCMSContainerData as q, reorderMenu as r, sendMessageToUVE as s, getColumnPositionClasses as t, updateNavigation as u, getContainersData as v, getContentletsInContainer as w, getDotCMSContainerData as x, getDotCMSContentletsBound as y, getDotCMSPageBounds as z };