UNPKG

@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
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