@dotcms/uve
Version:
Official JavaScript library for interacting with Universal Visual Editor (UVE)
1,475 lines (1,464 loc) • 49.5 kB
JavaScript
;
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;