diff --git a/CHANGELOG.md b/CHANGELOG.md index 461ab0e..a3460c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added new client event `amp:banner:mutated`. + +### Changed +- Banners are now tracked by MutationObserver to be able to send metric events from cloned banners, or close banners with buttons that were added after rendering. + ## [1.6.1] - 2024-10-11 ### Added - Added support for banners closing. diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 7046a5f..fa356a1 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -381,6 +381,7 @@ The client emits several events that can be responded to. Here is a list of them | `amp:banner:link-clicked` | `EVENTS.ON_BANNER_LINK_CLICKED` | `({ fingerprint: Fingerprint, element: HtmlElement, banner: Banner, target: HtmlElement, clickEvent: Event }) => void` | Fired when the user clicks on any link in a banner. | | `amp:banner:before-close` | `EVENTS.ON_BANNER_BEFORE_CLOSE` | `({ fingerprint: Fingerprint, element: HtmlElement, banner: Banner, setOperation: Function(operation: Function(el: HtmlElement) => void/Promise) => void }) => void` | Fired before the banner is closed by the user. The banner is simply removed by default. A callback can be passed to the `setOperation` argument to override this behavior. | | `amp:banner:after-close` | `EVENTS.ON_BANNER_AFTER_CLOSE` | `({ fingerprint: Fingerprint, element: HtmlElement, banner: Banner }) => void` | Fired after the banner is closed by the user. | +| `amp:banner:mutated` | `EVENTS.ON_BANNER_MUTATED` | `({ banner: Banner, mutation: MutationRecord }) => void` | Fired after the banner is closed by the user. | | `amp:fetch:before` | `EVENTS.ON_BEFORE_FETCH` | `() => void` | Fired before calling of AMP API. | | `amp:fetch:error` | `EVENTS.ON_BEFORE_ERROR` | `({ response: Object }) => void` | Fired when an API request failed or an error response was returned. | | `amp:fetch:success` | `EVENTS.ON_BEFORE_SUCCESS` | `({ response: Object }) => void` | Fired when an API returned a success response. | diff --git a/src/banner/banner-manager.mjs b/src/banner/banner-manager.mjs index 8632167..42ad5a4 100644 --- a/src/banner/banner-manager.mjs +++ b/src/banner/banner-manager.mjs @@ -5,6 +5,7 @@ import { Banner } from './banner.mjs'; import { State } from './state.mjs'; import { Fingerprint } from './fingerprint.mjs'; import { SequenceGenerator } from '../utils/sequence-generator.mjs'; +import { Events } from '../event/events.mjs'; import { getHtmlElement } from '../utils/dom-helpers.mjs'; export class BannerManager { @@ -13,6 +14,7 @@ export class BannerManager { #bannerRenderer; #sequenceGenerator; #banners = []; + #mutationObserver; /** * @param {EventBus} eventBus @@ -28,22 +30,38 @@ export class BannerManager { this.#dimensionsProvider = dimensionsProvider; this.#bannerRenderer = bannerRenderer; this.#sequenceGenerator = new SequenceGenerator(); + this.#mutationObserver = new MutationObserver((mutationList) => { + for (const mutation of mutationList) { + if (!(mutation.target instanceof HTMLElement)) { + continue; + } + + const uid = mutation.target.closest('[data-amp-attached]')?.dataset?.ampAttached; + const banner = undefined !== uid ? this.getBannerByUid(parseInt(uid)) : null; + + if (banner) { + this.#eventBus.dispatch(Events.ON_BANNER_MUTATED, { banner, mutation }); + } + } + }); this.STATE = State; } addExternalBanner(element, refWindow = window) { element = getHtmlElement(element, refWindow); + const uid = this.#sequenceGenerator.getNextIdentifier(); - element.setAttribute('data-amp-attached', ''); + element.setAttribute('data-amp-attached', uid); const banner = new ExternalBanner( this.#dimensionsProvider, this.#eventBus, - this.#sequenceGenerator.getNextIdentifier(), + uid, element, ); + this.#observeMutations(element); this.#banners.push(banner); return banner; @@ -55,20 +73,22 @@ export class BannerManager { } element = getHtmlElement(element, refWindow); + const uid = this.#sequenceGenerator.getNextIdentifier(); - element.setAttribute('data-amp-attached', ''); + element.setAttribute('data-amp-attached', uid); const banner = new ManagedBanner( this.#dimensionsProvider, this.#bannerRenderer, this.#eventBus, - this.#sequenceGenerator.getNextIdentifier(), + uid, element, position, resources, options, ); + this.#observeMutations(element); this.#banners.push(banner); return banner; @@ -77,18 +97,20 @@ export class BannerManager { addEmbedBanner(element, iframe, position, options) { element = getHtmlElement(element, window); iframe = getHtmlElement(iframe, window); + const uid = this.#sequenceGenerator.getNextIdentifier(); - element.setAttribute('data-amp-attached', ''); + element.setAttribute('data-amp-attached', uid); const banner = new EmbedBanner( this.#eventBus, - this.#sequenceGenerator.getNextIdentifier(), + uid, element, iframe, position, options, ); + this.#observeMutations(element); this.#banners.push(banner); return banner; @@ -138,4 +160,20 @@ export class BannerManager { return null; } + + #observeMutations(element) { + if (!element._ampBannerMutationsObserved) { + this.#mutationObserver.observe(element, { + subtree: true, + childList: true, + characterData: false, + attributes: true, + attributeFilter: [ + 'data-amp-banner-close', + ], + }); + + element._ampBannerMutationsObserved = true; + } + } } diff --git a/src/banner/closing/closing-manager.mjs b/src/banner/closing/closing-manager.mjs index 2a4d201..1aefca0 100644 --- a/src/banner/closing/closing-manager.mjs +++ b/src/banner/closing/closing-manager.mjs @@ -48,34 +48,60 @@ export class ClosingManager { } attachUi() { - this.#eventBus.subscribe(Events.ON_BANNER_STATE_CHANGED, ({ banner }) => { - if (banner.state === banner.STATE.RENDERED) { - Array.from(banner.element.querySelectorAll('[data-amp-banner-close]')).forEach(button => { - button.addEventListener('click', event => { - event.preventDefault(); + const attachClickEvent = ({ banner, button }) => { + if (button._ampCloseActionAttached) { + return; + } - const fingerprintEl = button.closest('[data-amp-banner-fingerprint]'); + button._ampCloseActionAttached = true; - if (!fingerprintEl) { - return; - } + button.addEventListener('click', event => { + event.preventDefault(); - const fingerprint = fingerprintEl.dataset.ampBannerFingerprint; - const bannerFingerprints = banner.fingerprints; + const fingerprintEl = button.closest('[data-amp-banner-fingerprint]'); - for (let i = 0; i < bannerFingerprints.length; i++) { - const bannerFingerprint = bannerFingerprints[i]; + if (!fingerprintEl) { + return; + } - if (bannerFingerprint.value === fingerprint) { - this.closeBanner(bannerFingerprint.bannerId); - } - } - }); + const fingerprint = fingerprintEl.dataset.ampBannerFingerprint; + const bannerFingerprints = banner.fingerprints; + + for (let i = 0; i < bannerFingerprints.length; i++) { + const bannerFingerprint = bannerFingerprints[i]; + + if (bannerFingerprint.value === fingerprint) { + this.closeBanner(bannerFingerprint.bannerId); + } + } + }); + }; + + this.#eventBus.subscribe(Events.ON_BANNER_STATE_CHANGED, ({ banner }) => { + if (banner.state === banner.STATE.RENDERED) { + Array.from(banner.element.querySelectorAll('[data-amp-banner-close]')).forEach(button => { + attachClickEvent({ banner, button }); }); } else if (banner.state === banner.STATE.CLOSED) { banner.element.innerHTML = ''; } }, null, -100); + + this.#eventBus.subscribe(Events.ON_BANNER_MUTATED, ({ banner, mutation }) => { + if (banner.state !== banner.STATE.RENDERED) { + return; + } + + if (0 < mutation.addedNodes.length) { + Array.from(banner.element.querySelectorAll('[data-amp-banner-close]')).forEach(button => { + attachClickEvent({ banner, button }); + }); + } + + if (mutation.attributeName) { + attachClickEvent({ banner, button: mutation.target }); + } + }); } isClosed(bannerId) { diff --git a/src/event/events.mjs b/src/event/events.mjs index beaa100..3ad196f 100644 --- a/src/event/events.mjs +++ b/src/event/events.mjs @@ -109,6 +109,18 @@ export class Events { return 'amp:banner:after-close'; } + /** + * Arguments: ( + * { + * banner: {Banner}, + * mutation: {MutationRecord} + * }, + * ) + */ + static get ON_BANNER_MUTATED() { + return 'amp:banner:mutated'; + } + /** * No arguments */ diff --git a/src/interaction/banner-interaction-watcher.mjs b/src/interaction/banner-interaction-watcher.mjs index 3ddd6cd..284969d 100644 --- a/src/interaction/banner-interaction-watcher.mjs +++ b/src/interaction/banner-interaction-watcher.mjs @@ -43,10 +43,16 @@ export class BannerInteractionWatcher { } }, null, -100); + this.#eventBus.subscribe(Events.ON_BANNER_MUTATED, ({ banner, mutation }) => { + if (State.RENDERED === banner.state && !banner.isEmbed() && 0 < mutation.addedNodes.length) { + this.#watchBanner(banner); + } + }); + const banners = this.#bannerManager.getBannersByState({ state: State.RENDERED, embed: false, - }) + }); for (let banner of banners) { this.#watchBanner(banner); @@ -75,8 +81,8 @@ export class BannerInteractionWatcher { } // when fingerprint element is not attached yet - if (undefined === element.dataset.ampBannerFingerprintObserved) { - element.dataset.ampBannerFingerprintObserved = 'true'; + if (!element._ampBannerFingerprintObserved) { + element._ampBannerFingerprintObserved = true; this.#intersectionObserver.observe(element); } @@ -84,11 +90,11 @@ export class BannerInteractionWatcher { for (let linkElement of linkElements) { // prevent multiple events - if (undefined !== linkElement.dataset.ampClickingAttached) { + if (linkElement._ampClickingMetricsAttached) { continue; } - linkElement.dataset.ampClickingAttached = 'true'; + linkElement._ampClickingMetricsAttached = true; linkElement.addEventListener('click', function (event) { const fingerprintMetadata = self.#fingerprints[fingerprint];