@kontent-ai/smart-link
Version:
Kontent.ai Smart Link SDK allowing to automatically inject [smart links](https://docs.kontent.ai/tutorials/develop-apps/build-strong-foundation/set-up-editing-from-preview#a-using-smart-links) to Kontent.ai according to manually specified [HTML data attri
279 lines • 12.4 kB
JavaScript
import { isElementWebComponent } from '../web-components/components';
import { AddButtonElementType, IFrameMessageType, } from './IFrameCommunicatorTypes';
import { SmartLinkRenderer } from './SmartLinkRenderer';
import { getAugmentableDescendants, isElementAugmentable } from '../utils/customElements';
import { validateAddInitialMessageData, validateEditButtonMessageData } from '../utils/messageValidation';
import { isInsideWebSpotlightPreviewIFrame } from '../utils/configuration';
import { buildKontentItemLink, buildKontentElementLink } from '../utils/link';
import { logError, logWarn } from './Logger';
import { InvalidEnvironmentError } from '../utils/errors';
export class DOMSmartLinkManager {
iframeCommunicator;
configuration;
mutationObserver;
intersectionObserver;
renderer;
enabled = false;
renderingTimeoutId = 0;
observedElements = new Set();
visibleElements = new Set();
constructor(iframeCommunicator, configuration) {
this.iframeCommunicator = iframeCommunicator;
this.configuration = configuration;
if (window === undefined || MutationObserver === undefined || IntersectionObserver === undefined) {
throw InvalidEnvironmentError('NodeSmartLinkProvider can only be initialized in a browser environment.');
}
this.mutationObserver = new MutationObserver(this.onDomMutation);
this.intersectionObserver = new IntersectionObserver(this.onElementVisibilityChange);
this.renderer = new SmartLinkRenderer(this.configuration);
}
toggle = (force) => {
const shouldEnable = force ? force : !this.enabled;
if (shouldEnable) {
this.enable();
}
else {
this.disable();
}
};
enable = () => {
if (this.enabled)
return;
this.startRenderingInterval();
this.listenToGlobalEvents();
this.observeDomMutations();
this.enabled = true;
};
disable = () => {
if (!this.enabled)
return;
this.stopRenderingInterval();
this.unlistenToGlobalEvents();
this.disconnectObservers();
this.renderer.clear();
this.enabled = false;
};
destroy = () => {
this.disable();
this.renderer.destroy();
};
augmentVisibleElements = () => {
requestAnimationFrame(() => {
this.renderer.render(this.visibleElements, this.observedElements);
});
};
/**
* Start an interval rendering (1s) that will re-render highlights for all visible elements using `setInterval`.
* It helps to adjust highlights position even in situations that are currently not supported by
* the SDK (e.g. element position change w/o animations, some infinite animations and other possible unhandled cases)
* for better user experience.
*/
startRenderingInterval = () => {
this.renderingTimeoutId = window.setInterval(this.augmentVisibleElements, 1000);
};
stopRenderingInterval = () => {
if (this.renderingTimeoutId) {
clearInterval(this.renderingTimeoutId);
this.renderingTimeoutId = 0;
}
};
listenToGlobalEvents = () => {
window.addEventListener('ksl:add-button:initial', this.onAddInitialClick, { capture: true });
window.addEventListener('ksl:add-button:action', this.onAddActionClick, { capture: true });
window.addEventListener('ksl:highlight:edit', this.onEditElement, { capture: true });
};
unlistenToGlobalEvents = () => {
window.removeEventListener('ksl:add-button:initial', this.onAddInitialClick, { capture: true });
window.removeEventListener('ksl:add-button:action', this.onAddActionClick, { capture: true });
window.removeEventListener('ksl:highlight:edit', this.onEditElement, { capture: true });
};
observeDomMutations = () => {
if (this.enabled)
return;
this.mutationObserver.observe(window.document.body, {
childList: true,
subtree: true,
});
getAugmentableDescendants(document, this.configuration).forEach((element) => {
if (element instanceof HTMLElement) {
this.observeElementVisibility(element);
}
});
};
disconnectObservers = () => {
this.mutationObserver.disconnect();
this.intersectionObserver.disconnect();
this.observedElements.forEach((element) => {
this.unobserveElementVisibility(element);
});
this.observedElements = new Set();
this.visibleElements = new Set();
};
observeElementVisibility = (element) => {
if (this.observedElements.has(element))
return;
this.intersectionObserver.observe(element);
this.observedElements.add(element);
};
unobserveElementVisibility = (element) => {
if (!this.observedElements.has(element))
return;
this.intersectionObserver.unobserve(element);
this.observedElements.delete(element);
this.visibleElements.delete(element);
};
onDomMutation = (mutations) => {
const relevantMutations = mutations.filter(isRelevantMutation);
for (const mutation of relevantMutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement))
continue;
if (isElementAugmentable(node, this.configuration)) {
this.observeElementVisibility(node);
}
for (const element of getAugmentableDescendants(node, this.configuration)) {
if (!(element instanceof HTMLElement))
continue;
this.observeElementVisibility(element);
}
}
for (const node of mutation.removedNodes) {
if (!(node instanceof HTMLElement))
continue;
if (isElementAugmentable(node, this.configuration)) {
this.unobserveElementVisibility(node);
}
for (const element of getAugmentableDescendants(node, this.configuration)) {
if (!(element instanceof HTMLElement))
continue;
this.unobserveElementVisibility(element);
}
}
}
if (relevantMutations.length > 0) {
this.augmentVisibleElements();
}
};
onElementVisibilityChange = (entries) => {
const filteredEntries = entries.filter((entry) => entry.target instanceof HTMLElement);
for (const entry of filteredEntries) {
const target = entry.target;
if (entry.isIntersecting) {
this.visibleElements.add(target);
}
else {
this.visibleElements.delete(target);
}
}
if (filteredEntries.length > 0) {
this.augmentVisibleElements();
}
};
onEditElement = (event) => {
const isInsideWebSpotlight = isInsideWebSpotlightPreviewIFrame(this.configuration);
const { data, targetNode } = event.detail;
const messageMetadata = {
elementRect: targetNode.getBoundingClientRect(),
};
const validationResult = validateEditButtonMessageData(data, this.configuration);
if (validationResult.type === 'error') {
logError(`Required attribute was not found: ${validationResult.missing[0]}. Skipped parsing these attributes: ${validationResult.missing.slice(1).join(', ')}`);
logError('Debug info: ', validationResult.debug);
logError('Skipping edit button click');
return;
}
if (validationResult.type === 'element') {
if (isInsideWebSpotlight) {
this.iframeCommunicator.sendMessage(IFrameMessageType.ElementClicked, validationResult.data, messageMetadata);
}
else {
const link = buildKontentElementLink({
environmentId: validationResult.data.projectId,
languageCodename: validationResult.data.languageCodename,
itemId: validationResult.data.itemId,
elementCodename: validationResult.data.elementCodename,
});
window.open(link, '_blank');
}
}
else if (validationResult.type === 'contentComponent') {
if (isInsideWebSpotlight) {
this.iframeCommunicator.sendMessage(IFrameMessageType.ContentComponentClicked, validationResult.data, messageMetadata);
}
else {
logWarn('Edit buttons for content components are only functional inside Web Spotlight.');
}
}
else {
if (isInsideWebSpotlight) {
this.iframeCommunicator.sendMessage(IFrameMessageType.ContentItemClicked, validationResult.data, messageMetadata);
}
else {
const link = buildKontentItemLink({
environmentId: validationResult.data.projectId,
languageCodename: validationResult.data.languageCodename,
itemId: validationResult.data.itemId,
});
window.open(link, '_blank');
}
}
};
onAddInitialClick = (event) => {
const isInsideWebSpotlight = isInsideWebSpotlightPreviewIFrame(this.configuration);
const { eventData, onResolve, onReject } = event.detail;
const { data, targetNode } = eventData;
const messageMetadata = {
elementRect: targetNode.getBoundingClientRect(),
};
console.log('data', data);
const messageDataValidation = validateAddInitialMessageData(data, this.configuration);
if (!messageDataValidation.success) {
const [first, ...rest] = messageDataValidation.missing;
onReject({
message: `Could not find required data attribute: '${first}'.${rest.length > 0 ? ` All attributes higher in hierarchy were never searched for: [${rest.join(', ')}]` : ''}`,
});
return;
}
if (!isInsideWebSpotlight) {
onReject({ message: 'Add buttons are only functional inside Web Spotlight' });
return;
}
this.iframeCommunicator.sendMessageWithResponse(IFrameMessageType.AddInitial, messageDataValidation.data, (response) => {
if (!response || response.elementType === AddButtonElementType.Unknown) {
console.log('response', JSON.stringify(response));
return onReject({ message: 'Something went wrong' });
}
return onResolve(response);
}, messageMetadata);
};
onAddActionClick = (event) => {
const isInsideWebSpotlight = isInsideWebSpotlightPreviewIFrame(this.configuration);
const { data, targetNode } = event.detail;
const messageMetadata = {
elementRect: targetNode.getBoundingClientRect(),
};
const messageDataValidation = validateAddInitialMessageData(data, this.configuration);
if (!messageDataValidation.success) {
return;
}
if (!isInsideWebSpotlight) {
logWarn('Add buttons are only functional inside Web Spotlight.');
return;
}
this.iframeCommunicator.sendMessage(IFrameMessageType.AddAction, {
...messageDataValidation.data,
action: data.action,
}, messageMetadata);
};
}
const isRelevantMutation = (mutation) => {
const isTypeRelevant = mutation.type === 'childList';
const isTargetRelevant = mutation.target instanceof HTMLElement && !isElementWebComponent(mutation.target);
if (!isTypeRelevant || !isTargetRelevant) {
return false;
}
const hasRelevantAddedNodes = Array.from(mutation.addedNodes).some((node) => node instanceof HTMLElement && !isElementWebComponent(node));
const hasRelevantRemovedNodes = Array.from(mutation.removedNodes).some((node) => node instanceof HTMLElement && !isElementWebComponent(node));
return hasRelevantAddedNodes || hasRelevantRemovedNodes;
};
//# sourceMappingURL=DOMSmartLinkManager.js.map