// External dependencies import debounce from 'lodash/debounce'; import filter from 'lodash/filter'; import forEach from 'lodash/forEach'; import get from 'lodash/get'; import has from 'lodash/has'; import includes from 'lodash/includes'; import isArray from 'lodash/isArray'; import isEmpty from 'lodash/isEmpty'; import isFunction from 'lodash/isFunction'; import isNaN from 'lodash/isNaN'; import isNull from 'lodash/isNull'; import isNumber from 'lodash/isNumber'; import isObject from 'lodash/isObject'; import isString from 'lodash/isString'; import isUndefined from 'lodash/isUndefined'; import set from 'lodash/set'; import startsWith from 'lodash/startsWith'; import $ from 'jquery'; // Internal dependencies import { getPercentage, hasValue, } from '@frontend-builder/utils/pure'; import { getUnit } from '@frontend-builder/utils/et-builder-sanitize'; import ETScriptWindowStore from '../stores/window'; import ETScriptDocumentStore from '../stores/document'; import ETScriptStickyStore from '../stores/sticky'; import { getOffsets, isBuilder, isDiviTheme, isExtraTheme, isFE, isLBP, isVB, isBFB, setImportantInlineValue, } from '../utils/utils'; import { getLimit, getStickyStyles, trimTransitionValue, } from '../utils/sticky'; import { toggleAllBackgroundLayoutClassnameOnSticky, } from './background-layout'; class ETScriptStickyElement { /** * Sticky element settings which is passed from builder. * * @since 4.6.0 * * @type {object} */ settings = {}; /** * Sticky element properties. * * @since 4.6.0 */ props = { id: null, $selector: null, position: null, topBottomPosition: null, customTopOffset: 0, customBottomOffset: 0, height: 0, heightSticky: 0, offsets: {}, isSticky: null, isPaused: null, pauseScrollTop: false, topLimitSettings: false, bottomLimitSettings: false, themeFixedPrimaryNavHeight: 0, }; /** * Props that need to be synced to sticky store so other sticky element can access * calculated value of other sticky element (eg. For offset surrounding). * * @since 4.6.0 * * @type {object} */ storeSync = [ 'id', 'isSticky', 'isPaused', 'customTopOffset', 'customBottomOffset', 'height', 'heightSticky', 'width', 'widthSticky', 'paddingSticky', 'offsets', 'topLimit', 'bottomLimit', 'topLimitSettings', 'bottomLimitSettings', ]; /** * Classname that is used for `); } } /** * Check if element uses particular sticky position * If responsive setting is used, this automatically match the result with current breakpoint. * * @since 4.6.0 * * @param {string} position * * @returns {bool} */ hasSticky = position => position === this.getSetting(position); /** * Convert module defined offset into px-based eqivalent. Calculation is performed in px. * * @todo Convert more unit to px. * @param settingOffset * @since 4.6.0 * @param {string} * @returns {number} */ parseOffsetToPx = settingOffset => { if (! hasValue(settingOffset)) { return 0; } const unit = getUnit(settingOffset); let pxOffset = 0; switch (unit) { case 'px': pxOffset = parseInt(settingOffset); break; // No known unit; treat it as unitless px default: pxOffset = parseInt(settingOffset); break; } return pxOffset; } /** * Get accurate module width. * * @since 4.6.0 * * @returns {number} */ getModuleWidth = () => { const $element = this.getProp('$selector'); const element = document.querySelector(`${this.getSetting('selector')}:not(.et_pb_sticky_placeholder)`); const width = parseInt($element.outerWidth()); // getComputedStyle() tends to get more accurate computed width down to three decimal digits // compared to jQuery's outerWidth(). This is mostly needed when the module's width is less // than its wrapper and have auto width like button module where a slight width difference could // create unwanted newline break when the inaccurate fixed width is added on sticky state if (isFunction(window.getComputedStyle) && ! isNull(element)) { const selector = `${this.getSetting('selector')}:not(.et_pb_sticky_placeholder)`; const preciseWidth = parseFloat(getComputedStyle(document.querySelector(selector)).width); if (preciseWidth > width) { return preciseWidth; } } return width; } /** * Get overall offset position. * * @since 4.6.0 * @param {string} position Top|bottom. * @param include * @param {string | Array} all|custom|surrounding|knownElement */ getOffset = (position, include = 'all') => { // Determine whether given offsetType should be included in offset calculation const shouldInclude = offsetType => 'all' === include || offsetType === include || (isArray(include) && includes(include, offsetType)); const offsetPropName = 'top' === position ? 'customTopOffset' : 'customBottomOffset'; const customOffset = shouldInclude('custom') ? this.getProp(offsetPropName) : 0; const isTopPosition = 'top' === position; const isBottomPosition = 'bottom' === position; const topOffsetModules = isTopPosition && this.getProp('topOffsetModules'); const bottomOffsetModules = 'bottom' === position && this.getProp('bottomOffsetModules'); const offsetSurrounding = 'on' === this.getSetting('offsetSurrounding'); const offsetModules = ! offsetSurrounding ? false : isTopPosition ? topOffsetModules : bottomOffsetModules; // Surrounding module offset let surroundingOffsetTop = 0; if (shouldInclude('surrounding') && offsetModules) { forEach(offsetModules, id => { // Make sure that valid number is passed to surroundingOffsetTop. Double check since `NaN` // is considered number (which will mess the calculation if passed) const surroundingCustomOffsetTop = ETScriptStickyStore.getProp(id, offsetPropName, 0); if (isNumber(surroundingCustomOffsetTop) && surroundingCustomOffsetTop) { surroundingOffsetTop += ETScriptStickyStore.getProp(id, offsetPropName, 0); } const surroundingHeight = ETScriptStickyStore.getProp(id, 'heightSticky', 0); if (isNumber(surroundingHeight) && surroundingHeight) { surroundingOffsetTop += ETScriptStickyStore.getProp(id, 'heightSticky', 0); } }); } // Known Element Offset let knownElementOffset = 0; if (shouldInclude('knownElement')) { // Always include admin bar height into offset consideration if it exist if (isTopPosition && ! isLBP && ETScriptStickyStore.getElementProp('wpAdminBar', 'exist', false)) { knownElementOffset += ETScriptStickyStore.getElementProp('wpAdminBar', 'height', 0); } if (isBottomPosition && isLBP && 600 <= ETScriptWindowStore.width && ETScriptStickyStore.getElementProp('wpAdminBar', 'exist', false)) { knownElementOffset += ETScriptStickyStore.getElementProp('wpAdminBar', 'height', 0); } // Responsive View'control in VB's responsive mode creates additional offset to be considered. if (ETScriptStickyStore.getElementProp('builderAppFramePaddingTop', 'exist', false)) { const appFramePaddingTop = ETScriptStickyStore.getElementProp('builderAppFramePaddingTop', 'height', 0); if (isTopPosition && isBFB) { knownElementOffset -= appFramePaddingTop; } if (isBottomPosition && isBuilder) { knownElementOffset += appFramePaddingTop; } } // Include Divi fixed secondary nav height into offset calculation if it exist if (isTopPosition && offsetModules && ETScriptStickyStore.getElementProp('diviFixedSecondaryNav', 'exist', false)) { knownElementOffset += ETScriptStickyStore.getElementProp('diviFixedSecondaryNav', 'height', 0); } // Include Divi fixed primary nav height into offset calculation if it exist const themeFixedHeaderName = this.getThemeFixedPrimaryNavName(); if (isTopPosition && offsetModules && ETScriptStickyStore.getElementProp(themeFixedHeaderName, 'exist', false)) { const themeFixedPrimaryNavHeight = ETScriptStickyStore.getElementProp(themeFixedHeaderName, 'height', 0); knownElementOffset += themeFixedPrimaryNavHeight; // Save currently used Divi fixed nav height on prop so it can be used later for reference this.setProp('themeFixedPrimaryNavHeight', themeFixedPrimaryNavHeight); } // If fixed primary nav `exist` prop doesn't exist at sticky store but its `height` prop // is not `0` locally on ETScriptStickyElement's prop it means primary nav previously exist as // fixed nav but now the breakpoint changes and it is no longer has fixed positioning; it // sits on top of the page instead (eg. at VB tablet mode). Update local prop to avoid // incorrect compariosn which causes bottom limit to break if (! ETScriptStickyStore.getElementProp(themeFixedHeaderName, 'exist', false) && 0 !== this.getProp('themeFixedPrimaryNavHeight')) { this.setProp('themeFixedPrimaryNavHeight', 0); } // Calculate Theme Builder's header which affect bottom positioning if (isBottomPosition && ETScriptStickyStore.getElementProp('tbHeader', 'exist', false)) { knownElementOffset += ETScriptStickyStore.getElementProp('tbHeader', 'height', 0); } // Calculate Layout Block Builder's header which affect bottom positioning if (isBottomPosition && ETScriptStickyStore.getElementProp('lbbHeader', 'exist', false)) { knownElementOffset += ETScriptStickyStore.getElementProp('lbbHeader', 'height', 0); } // Calculate Gutenberg Header if (isBottomPosition && isLBP && ETScriptStickyStore.getElementProp('gbHeader', 'exist', false)) { knownElementOffset += ETScriptStickyStore.getElementProp('gbHeader', 'height', 0); } // Calculate Gutenberg Footer (WordPress 5.4+) if (isBottomPosition && isLBP && ETScriptStickyStore.getElementProp('gbFooter', 'exist', false)) { knownElementOffset += ETScriptStickyStore.getElementProp('gbFooter', 'height', 0); } // Calculate notice list component if (isBottomPosition && isLBP && ETScriptStickyStore.getElementProp('gbComponentsNoticeList', 'exist', false)) { knownElementOffset += ETScriptStickyStore.getElementProp('gbComponentsNoticeList', 'height', 0); } } return customOffset + surroundingOffsetTop + knownElementOffset; } /** * Get formatted horizontal/vertical offset of relative position. * * @since 4.6.0 * * @param {string} direction Vertical|horizontal. * * @returns {number} */ getRelativePositionOffset = direction => { const relativeOrigin = this.getSetting('stickyStyles.position_origin_r'); const originIndex = 'vertical' === direction ? 0 : 1; const origin = isString(relativeOrigin) ? relativeOrigin.split('_')[originIndex] : false; const offsetPx = this.getPropertyValueInPx(`stickyStyles.${direction}_offset`); const multiplier = { top: 1, bottom: - 1, left: 1, right: - 1, }; if (origin && offsetPx) { return 0 + (offsetPx * get(multiplier, origin, 1)); } return 0; } /** * Get current sticky element placeholder * Add identifieable data attribute and call it based on given data attribute. * * @since 4.6.0 * * @returns {object} Jquery instance of placeholder. */ getPlaceholder = () => $(`[data-sticky-placeholder-id="${this.getProp('id')}"]`) /** * Get property value in px. Some unit might cause unwanted rendering so it needs to be converted * into px (eg. Percentage unit uses viewport dimension as reference in fixed positioning * sticky state hence needs to be converted into px for it to correctly rendered). * * @param {string} settingName * @param {string|number} defaultValue * * @returns {number} */ getPropertyValueInPx = (settingName, defaultValue = '') => { const value = this.getSetting(settingName); if (! isString(value) || includes(['none', 'auto'], value) || ! hasValue(value)) { return defaultValue; } if ('%' === value.substr(- 1)) { const parentWidth = this.getProp('$selector').parent().width(); return getPercentage(parentWidth, value); } if ('vw' === value.substr(- 2)) { return getPercentage(ETScriptWindowStore.width, value); } if ('vh' === value.substr(- 2)) { return getPercentage(ETScriptWindowStore.height, value); } return parseFloat(value); } /** * Get sticky element's final `left` inline style. To accommodate smooth transitioning, some * sticky inline style need to have its non-sticky value at first then "final" sticky style * being added 50ms afterward. This is mostly needed due to transition from relative to fixed * positioning (which is relative to viewport) + transition. * * @since 4.6.0 * * @returns {number\string} */ getFinalInlineStyleLeft = () => { const moduleAlignment = this.getSetting('styles.module_alignment', ''); let offsetLeft = get(this.getProp('offsets'), 'left', 0); // Pre-sticky state offset left is already correct sticky state offset for module alignment left. if (includes(['', 'left'], moduleAlignment)) { return offsetLeft; } // `left` property is building block of sticky element which is used to retain position of // module when entering sticky state. Thus `position: relative`'s horizontal offset which // modifies `left` needs to intentionally modifies inline final `left` of sticky module if (this.getSetting('stickyStyles.position_origin_r')) { // Add / subtract offset left based on retrieved `position: relative` horizontal offset offsetLeft += this.getRelativePositionOffset('horizontal'); } // width property is affected by max-width property; smaller max-width means max-width will be // used instead of width value const width = this.getProp('width'); const stickyWidthPx = this.getPropertyValueInPx('stickyStyles.width', this.getPropertyValueInPx('styles.width', '')); const renderedMaxWidth = parseFloat(this.getProp('$selector').css('maxWidth')); const stickyMaxWidthPx = this.getPropertyValueInPx('stickyStyles.max-width', this.getPropertyValueInPx('styles.max-width', isNaN(renderedMaxWidth) ? '' : renderedMaxWidth)); const usedWidthProperty = () => { if (hasValue(stickyWidthPx) && ! hasValue(stickyMaxWidthPx)) { return 'width'; } if (! hasValue(stickyWidthPx) && hasValue(stickyMaxWidthPx)) { return 'max-width'; } return stickyWidthPx > stickyMaxWidthPx ? 'max-width' : 'width'; }; // If max-width is picked css property because no width value is set, compare its width with // existing width prop to ensure that max-width is smaller than width; otherwise just use // current width property if ('max-width' === usedWidthProperty() && ! hasValue(stickyWidthPx) && width < stickyMaxWidthPx) { return offsetLeft; } const stickyStyleWidthDefault = 'max-width' === usedWidthProperty() ? stickyMaxWidthPx : stickyWidthPx; const stickyStyleWidth = this.getSetting(`stickyStyles.${usedWidthProperty()}`, this.getSetting(`styles.${usedWidthProperty()}`, stickyStyleWidthDefault)); // Get sticky style left offset if module width in sticky state is different. // Module align center additional offset: 50% of width difference between pre and sticky state width. // Module align right additional offset: 100% of width difference between pre and sticky state width. const leftOffsetDivider = 'center' === moduleAlignment ? 2 : 1; if (isString(stickyStyleWidth) && hasValue(stickyStyleWidth)) { if ('%' === stickyStyleWidth.substr(- 1)) { const parentWidth = this.getProp('$selector').parent().width(); return offsetLeft - ((getPercentage(parentWidth, stickyStyleWidth) - width) / leftOffsetDivider); } if ('vw' === stickyStyleWidth.substr(- 2)) { return offsetLeft - ((getPercentage(ETScriptWindowStore.width, stickyStyleWidth) - width) / leftOffsetDivider); } if ('vh' === stickyStyleWidth.substr(- 2)) { return offsetLeft - ((getPercentage(ETScriptWindowStore.height, stickyStyleWidth) - width) / leftOffsetDivider); } return offsetLeft - ((parseInt(stickyStyleWidth) - width) / leftOffsetDivider); } if (isNumber(stickyStyleWidth)) { return offsetLeft - ((stickyStyleWidth - width) / leftOffsetDivider); } return offsetLeft; } /** * Check if element is on sticky state * Top sticky state: `top` OR `top_bottom` element position + window scroll top which its value is lower * (visually higher) than element's default top offset * Bottom sticky state:`bottom` or `top_bottom` position + window scroll which its value is higher * (visually lower) than element's default bottom offset (top offset + element height). * * @since 4.6.0 * * @param {string} position Top|Bottom. * * @returns {bool} */ isStickyScroll = position => { const hasTopOrBottomPosition = this.isProp('position', position); const isPositionSticky = this.isProp('topBottomPosition', position); return hasTopOrBottomPosition || (this.isProp('position', 'top_bottom') && isPositionSticky); } /** * Check if element will have sticky state on current event scroll. Element with `top` / `bottom` * position is pretty straightforward; `top_bottom` element requires further evaluetion since * it can be either `top` or `bottom` depending to current window scroll top position. * * @since 4.6.0 * @param position * @param {*} side Top|Bottom. */ willStickyScroll = position => { // Return early if it is either `top` / `bottom` position const hasTopOrBottomPosition = this.isProp('position', position); if (hasTopOrBottomPosition) { return true; } // Beside top / bottom evaluation, another valid position is `top_bottom`. Otherwise, return false if (! this.isProp('position', 'top_bottom')) { return false; } // Check top or bottom const isTop = 'top' === position; // Element dimension const stickyHeight = this.getProp('height'); const stickyOffsetTop = get(this.getProp('offsets'), 'top', 0); // Window attributes const windowScrollTop = ETScriptWindowStore.scrollTop; const windowPositionEdge = isTop ? windowScrollTop + this.getOffset('top') : windowScrollTop + ETScriptWindowStore.height - this.getOffset('bottom'); // Evaluate top / bottom Position, whether it is currently on top or bottom sticky state const isPositionSticky = this.isProp('topBottomPosition', position); const willPositionSticky = isTop ? windowPositionEdge >= stickyOffsetTop : windowPositionEdge < (stickyOffsetTop + stickyHeight); const willEndingPositionSticky = ! willPositionSticky && isPositionSticky; // Update is position sticky if it is changed; if (isPositionSticky !== willPositionSticky) { this.setProp('topBottomPosition', position); } // Is top_bottom is on scticky scroll position return willPositionSticky || willEndingPositionSticky; } /** * ET Window Scroll store's scroll event callback. * * @since 4.6.0 */ onWindowScroll = () => { // If current position is set to `none` in current breakpoint, do not process further // (this is possible when responsive is turned on; sticky is disabled in certain breakpoint) if (this.isProp('position', 'none')) { return; } const isAppWindowScroll = 'app' === ETScriptWindowStore.scrollLocation; const windowScrollTop = ETScriptWindowStore.scrollTop; // Whether element is currently sticky / paused or not const isSticky = this.getProp('isSticky'); const isPaused = this.getProp('isPaused'); // Adjust sticky element positioning if Divi Theme's fixed height changes on window scroll // need to be used before any getOffset(); willStickyScroll() uses getOffset() if (isDiviTheme && (isFE || isVB) && isSticky && this.isStickyScroll('top')) { const savedThemeFixedHeaderHeight = this.getProp('themeFixedPrimaryNavHeight', 0); const currentThemeFixedHeaderHeight = ETScriptStickyStore.getElementProp(this.getThemeFixedPrimaryNavName(), 'height', 0); // Check if used fixed nav height differs from current one which is fetched from store if (savedThemeFixedHeaderHeight !== currentThemeFixedHeaderHeight) { // This adjustment is adapted from startSticky(); adjust sticky element's top value const isScrollLocationApp = 'app' === ETScriptWindowStore.scrollLocation; const top = isScrollLocationApp ? 0 + this.getOffset('top') : ETScriptWindowStore.scrollTop + this.getOffset('top'); this.getProp('$selector').css({ top: `${top}px`, }); } } // element's coordinates const pauseScrollTop = this.getProp('pauseScrollTop'); // element's dimension const stickyHeight = this.getProp('height'); const stickyOffsetTop = get(this.getProp('offsets'), 'top', 0); // Top / bottom limit const bottomLimit = this.getProp('bottomLimitSettings'); const topLimit = this.getProp('topLimitSettings'); // Check if element will enter sticky state on this scroll event or not const willTopStickyScroll = this.willStickyScroll('top'); const willBottomStickyScroll = this.willStickyScroll('bottom'); // Whether element will be sticky or not; to be overwritten let willSticky = this.getProp('isSticky'); let willPause = this.getProp('isPaused'); // Window top and bottom edges; Define the var on top of conditional so it can be used later let windowTopEdge = 0; let windowBottomEdge = 0; // Determine whether element in current position qualified for sticky state or not // @todo scrollTop needs to be aware of manual and automatic offset if (willTopStickyScroll) { windowTopEdge = windowScrollTop + this.getOffset('top'); // Sticky state active when window's top offset is greater (visually lower) than // sticky element's top offset willSticky = windowTopEdge > stickyOffsetTop; if (bottomLimit) { const bottomLimitOffsetBottom = get(bottomLimit, 'offsets.bottom', 0) - this.getOffset('bottom', 'surrounding'); willPause = bottomLimitOffsetBottom <= (windowTopEdge + stickyHeight); } } else if (willBottomStickyScroll) { const windowHeightScale = ETScriptWindowStore.isBuilderZoomed ? 2 : 1; windowBottomEdge = windowScrollTop + (ETScriptWindowStore.height * windowHeightScale) - this.getOffset('bottom'); // Sticky state active when window's bottom offset is smaller (visually higher) than // sticky element's bottom offset (sticky offset.top + height) willSticky = windowBottomEdge < (stickyOffsetTop + stickyHeight); if (topLimit) { const topLimitOffsetTop = get(topLimit, 'offsets.top', 0) + this.getOffset('top', 'surrounding'); willPause = topLimitOffsetTop >= (windowBottomEdge - stickyHeight); } } // Activate sticky element if (willSticky && ! isSticky) { // Before actually start sticky state, check if the DOM is visible or not. Invisible DOM // (mostly caused on builder when module is dragged before dropped) will cause unexpected // positioning to the left of the page if (! this.getProp('$selector').is(':visible')) { // Reset the `willSticky`-ness to avoid unexpected behaviour willSticky = false; } else { // Module DOM is actually visible? Let the sticky begins this.startSticky(); } } // Deactivate sticky element if (! willSticky && isSticky) { this.endSticky(); } // Pause sticky state because it has reached limit if (willPause && ! isPaused && isAppWindowScroll) { this.pauseSticky(); } // Resume sticky state because it returns from limit if (! willPause && isPaused && isAppWindowScroll) { this.resumeSticky(); } if (willPause && false !== pauseScrollTop && isAppWindowScroll) { if (willTopStickyScroll && bottomLimit) { const pauseMarginTop = pauseScrollTop - windowScrollTop - this.getOffset('bottom', 'surrounding'); setImportantInlineValue(this.getProp('$selector'), 'margin-top', `${pauseMarginTop}px`); } else if (willBottomStickyScroll && topLimit) { const pauseMarginBottom = windowScrollTop - pauseScrollTop - this.getOffset('top', 'surrounding'); setImportantInlineValue(this.getProp('$selector'), 'margin-bottom', `${pauseMarginBottom}px`); } } // Update sticky state if stickiness state is changed if (willSticky !== isSticky) { this.setProp('isSticky', willSticky); } // Update pause state if pause state is changed if (willPause !== isPaused) { this.setProp('isPaused', willPause); } // Adjust scroll behaviour when scroll is on top window if (! isAppWindowScroll) { // Adjust the sticky position as the top window is scrolled if (willSticky && ! willPause) { if (this.isStickyScroll('top')) { this.getProp('$selector').css({ top: `${windowTopEdge}px`, // equivalent to ETScriptWindowStore.scrollTop }); } if (this.isStickyScroll('bottom')) { this.getProp('$selector').css({ top: `${windowBottomEdge - stickyHeight}px`, }); } } // Adjust sticky position when the scroll hits exactly paused offset if (willPause && ! isPaused) { if (this.isStickyScroll('top')) { // Essentially equivalent to bottomLimitOffsetBottom but it isn't always defined here const topWindowBottomLimitOffsetBottom = get(bottomLimit, 'offsets.bottom', 0) - this.getOffset('bottom', 'surrounding'); this.getProp('$selector').css({ top: `${topWindowBottomLimitOffsetBottom - stickyHeight}px`, }); } if (this.isStickyScroll('bottom')) { // Essentially equivalent to topLimitOffsetTop but it isn't always defined here const topWindowTopLimitOffsetTop = get(topLimit, 'offsets.top', 0) + this.getOffset('top', 'surrounding'); this.getProp('$selector').css({ top: `${topWindowTopLimitOffsetTop}px`, }); } } } } /** * Event listener callback which update props when scroll location is changed (builder only). * * @since 4.6.0 */ onWindowScrollLocationChange = debounce(() => { // What needs to be updated is identical to what happens on breakpoint change. Nevertheless // it needs to be delayed until builder preview mode animation change is completed hence // the debounce + trailing option this.onBreakpointChange(); }, 2000, { leading: false, trailing: true, }) /** * Event listener callback to Update sticky props and element attribute when * window width is changed. * * @since 4.6.0 */ onWindowWidthChange = debounce(() => { // Update states so next time module enter sticky state it'll use correct value this.setInitialProps(); const isSticky = this.getProp('isSticky'); // If module is currently in sticky state, update style properties right away if (isSticky) { this.updateInlineStyles(); } }, 50, { trailing: true, }) /** * Event listener callback to Update sticky props and element attribute when * window height is changed. * * @since 4.6.0 */ onWindowHeightChange = debounce(() => { // If window height is changed, paused sticky needs to be updated if (this.getProp('isPaused')) { this.pauseSticky(); } }, 50); /** * Event listener callback to update sticky props when document height / width is changed * eg. Toggle module is expanded which makes document taller than before. * * @since 4.6.0 */ onDocumentDimensionChange = debounce(() => { // Update sticky element and its limits' offsets; If what changes document height happened on top // of element, existing offset values are no longer accurate; update it. this.setInitialProps(true); // Update module's width and left properties (offset) if current element is in sticky state if (this.getProp('isSticky')) { this.updateInlineStyles(); } // Re-paused sticky / re-render styles for paused element to ensure the element is stopped // in correct limits if (this.getProp('isPaused')) { this.pauseSticky(); } // Trigger window scroll event callback to immediately re-apply sticky limit inline style which // is possibly reset after this callback this.onWindowScroll(); }, 50, { trailing: true, }) /** * Event listener callback to update sticky props when breakpoint is changed so responsive * options can be rendered correctly. * * @since 4.6.0 */ onBreakpointChange = () => { const prevProps = { ...this.props, }; const wasPositionNone = 'none' === get(prevProps, 'position'); // End sticky to remove all inline styles and placeholder if (! wasPositionNone) { this.endSticky(); this.setProp('isSticky', false); } // Re-set props using correct breakpoint value this.setInitialProps(); const position = this.getProp('position'); const isPositionNone = 'none' === position; // New breakpoint set position to none if (isPositionNone && ! wasPositionNone) { return; } // Trigger on window scroll callback so startSticky can be called if current scroll position // after breakpoint change is still in sticky state this.onWindowScroll(); } /** * Event listener callback when DOM of Sticky Element change. It needs to be observed because * element dimension might change (which affect offset calculation) but it doesn't modify * window and document height. * * @since 4.6.0 */ onDomChange = debounce((mutationList, observer) => { const height = parseFloat(this.getProp('$selector').outerHeight()); const width = parseFloat(this.getProp('$selector').outerWidth()); const suffix = this.getProp('isSticky') ? 'Sticky' : ''; if (! isNaN(width) && width !== this.getProp(`width${suffix}`)) { this.setProp(`width${suffix}`, width); } if (! isNaN(height) && height !== this.getProp(`height${suffix}`)) { this.setProp(`height${suffix}`, height); } }, 500) /** * Update initial props when Divi fixed header transition ends. * * @since 4.6.0 * * @param {object} event */ onDiviFixedHeaderTransitionEnd = event => { // If current sticky module is in sticky state + already hits its limit (paused), Divi fixed // header transition which apparently also modifies negative margin-top at #page-container // will affect paused sticky module position. To fix it, update limit offset value by // Re-populate initial props (kinda like how props are re-initialized after breakpoint change ) if (this.getProp('isSticky') && this.getProp('isPaused')) { // End sticky to remove all inline styles and placeholder this.endSticky(); this.setProp('isSticky', false); // Re-initialized props this.setInitialProps(); // Trigger window scroll callback setTimeout(() => { this.onWindowScroll(); }, 0); } } /** * Toggle has sticky classname at affecting parents. This is needed because to make sticky * element rendered on top of other element despite default stacking order. * * @since 4.6.0 * @param status * @param {bool} */ toggleAffectingParentsClassname = status => { const $builderWrapper = this.getProp('$selector').closest('.et_builder_inner_content'); const $column = this.getProp('$selector').parents('.et_pb_column'); const wrapperClassname = 'has_et_pb_sticky'; if (status) { // Add has sticky classname. addClass won't double add classname so this would be fine $builderWrapper.addClass(wrapperClassname); if ($column.length > 0) { $column.addClass(wrapperClassname); } } else { // Only remove builder wrapper's has sticky classname IF there are no active sticky element // on current builder wrapper if ($builderWrapper.find('.et_pb_sticky').length < 1) { $builderWrapper.removeClass(wrapperClassname); } // Only remove column's has sticky classname IF there are no active sticky element // on current column if ($column.length > 0 && $column.find('.et_pb_sticky').length < 1) { $column.removeClass(wrapperClassname); } } } /** * Set style to activate sticky state on current element. * * @since 4.6.0 */ startSticky = () => { const isScrollLocationApp = 'app' === ETScriptWindowStore.scrollLocation; const dataAddress = hasValue(this.getProp('$selector').attr('data-address')) ? `placeholder-${this.getProp('$selector').attr('data-address')}` : null; // Clone original module as placeholder, add classname so it can easily excluded, then // insert it on module's original location to avoid jiggling layout when module enters / // leaves sticky state. This also serves to retrieve updated style property when window // dimension is changed (previously use empty placeholder with dynamic value but JS // can't only computed style - this behaves poorly on module which uses percentage based // width and margin auto such as row). const $placeholder = this.getProp('$selector').clone().addClass('et_pb_sticky_placeholder').attr({ 'data-sticky-placeholder-id': this.getProp('id'), // data-address is used as reference for drag and drop in VB thus it should be unique 'data-address': dataAddress, }) .css({ position: '', top: '', left: '', bottom: '', zIndex: '', width: '', marginTop: '', marginRight: '', marginBottom: '', marginLeft: '', padding: '', }); // Remove all VB's custom CSS inside placeholder so it won't overwrite actual module's // style if it is being modified while module is in sticky state; placeholder only need // the HTML markup to hold the layout's position (since it has all the actual module's // classname which makes it has the same styling) when module entire sticky state $placeholder.find('.et-fb-custom-css-output').remove(); // Remove on-page helper style $placeholder.find('.et_pb_sticky_module_style').remove(); // Lock sticky module's parent when the module enters sticky state. There's a ms of time gap // between placeholder being added and sticky module being fixed positioning which could cause // the layout to jump. This is generally invisible but become very visible in BFB. The solution, // is to get sticky module parent's height, lock its height using !important tag, then remove // the height lock a milisecond after placeholder is added and sticky enter sticky state this.lockParentHeight(); this.getProp('$selector').after($placeholder); // Placeholder height doesn't match to its actual selector height most likely means image inside // placeholder isn't fully loaded due to slow connection. Compensate this by adding fixed height // and width on image inside placeholder if ($placeholder.height() !== this.getProp('$selector').height()) { const $stickyModule = this.getProp('$selector'); $placeholder.find('img').each(function(imageIndex) { const imageHeight = $stickyModule.find(`img:nth(${imageIndex})`).height(); const imageWidth = $stickyModule.find(`img:nth(${imageIndex})`).width(); const imageInlineStyle = { 'height': `${imageHeight}px`, 'width': `${imageWidth}px`, }; // Remove inline fixed height style once the image is loaded $(this).css(imageInlineStyle).on('load', function() { $(this).css({ height: '', width: '', }); }); }); } // Add sticky element classnames; This is needed for child element's sticky style this.getProp('$selector').addClass(`et_pb_sticky et_pb_sticky--${this.getProp('position')}`); // Dispatch custom event which frontend script can listen and react to window.dispatchEvent(new CustomEvent('ETBuilderStickyStart', { detail: { stickyId: this.getProp('id'), }, })); // Add classname to builder wrapper to temporarily change its stacking order this.toggleAffectingParentsClassname(true); // Get dynamically adjusted z-index value for sticky module const stickyZindex = () => { // Set higher z-index for sticky element on TB header because it always need to be stacked // on top of other sticky from different TB template. if (this.getProp('isInsideTbHeader')) { return 10010; } // Set lower z-index for sticky element on TB footer because it always need to be stacked // below other sticky from different TB template. if (this.getProp('isInsideTbFooter')) { return 9990; } return 10000; }; // Set inline styles that activate sticky element // NOTE: position:fixed; is initially defined on `stickyStyles` using `css()` but position // opitions add `position: relative !important` by default so sticky elements need to use // much `position: fixed !important` via `css()`'s cssText` property const widthStickyStyle = this.getProp('widthSticky'); const leftStickyStyle = get(this.getProp('offsets'), 'left', 0); const stickyStyles = { zIndex: stickyZindex(), width: isNumber(widthStickyStyle) ? `${widthStickyStyle}px` : widthStickyStyle, left: isNumber(leftStickyStyle) ? `${leftStickyStyle}px` : leftStickyStyle, }; if (this.isStickyScroll('top')) { if (isScrollLocationApp) { stickyStyles.top = `${0 + this.getOffset('top')}px`; } else { stickyStyles.top = `${ETScriptWindowStore.scrollTop + this.getOffset('top')}px`; stickyStyles['will-change'] = 'top'; } // Ensures counter-side style; stickyStyles.bottom = 'auto'; // Some element might have margin-bottom style; reset it; stickyStyles.marginTop = '0px'; } if (this.isStickyScroll('bottom')) { if (isScrollLocationApp) { stickyStyles.bottom = `${0 + this.getOffset('bottom')}px`; } else { stickyStyles['will-change'] = 'top'; } // Ensures counter-side style; stickyStyles.top = 'auto'; // Some element might have margin-bottom style; reset it; stickyStyles.marginBottom = '0px'; } // Determine if `position: relative` is set by builder by checking the value of // position relative's origin attribute const stickyRelativeOrigin = this.getSetting('stickyStyles.position_origin_r'); const cssTransitions = this.getProp('$selector').css('transition'); // Remove `position: relative` offset's transition from transition property declaration by // overwriting it with trimmed transition declaration. This is needed to avoid unwanted // animation because when module enter sticky state, its position change to fixed which // makes the x,y axis moves from its parent to viewport. This transition will later be // re-executed by removing the inline `transition` style if (stickyRelativeOrigin && 'on' === this.getSetting('transition')) { stickyStyles.transition = trimTransitionValue(cssTransitions, ['top', 'right', 'bottom', 'left']); } // Position on sticky start let cssText = `position: fixed !important; padding: ${this.getProp('paddingSticky')} !important;`; // Set margin difference as inline style; margin in sticky style can't be simply used because // of the position value changed from relative to fixed when entering sticky state const marginRight = this.getProp('marginRight'); const marginLeft = this.getProp('marginLeft'); const marginRightSticky = this.getProp('marginRightSticky'); const marginLeftSticky = this.getProp('marginLeftSticky'); if (0 !== marginRightSticky || 0 !== marginRight) { cssText += ` margin-right: ${marginRightSticky}px !important;`; } if (0 !== marginLeftSticky || 0 !== marginLeft) { cssText += ` margin-left: ${marginLeftSticky}px !important;`; } // Set inline style for sticky element this.getProp('$selector').css({ cssText }).css(stickyStyles); // Remove sticky module parent height lock; Ensure removal to be performed after sticky module // enters sticky state by setting it up after one milisecond of timeout setTimeout(() => { this.unlockParentHeight(); }, 1); // Sticky style of css property that is used to construct sticky element need to be applied // after sticky element is constructed to ensure transition is correctly applied since // transition need initial and final property; otherwise it'll cause style jump with no transition const stickyStyleWidth = this.getPropertyValueInPx('stickyStyles.width', this.getPropertyValueInPx('styles.width', '')); const stickyStyleMaxWidth = this.getPropertyValueInPx('stickyStyles.max-width'); if (hasValue(stickyStyleWidth) || hasValue(stickyStyleMaxWidth) || stickyRelativeOrigin) { this.startStickyFinalStyleTimeout = setTimeout(() => { const finalStickyStyle = {}; const finalStickyStyleLeft = this.getFinalInlineStyleLeft(); // Append final inline css property only if it returns valid value if (isNumber(finalStickyStyleLeft)) { finalStickyStyle.left = `${finalStickyStyleLeft}px`; } if (hasValue(stickyStyleWidth)) { finalStickyStyle.width = isNumber(stickyStyleWidth) ? `${stickyStyleWidth}px` : stickyStyleWidth; } if (hasValue(stickyStyleMaxWidth)) { finalStickyStyle['max-width'] = isNumber(stickyStyleMaxWidth) ? `${stickyStyleMaxWidth}px` : stickyStyleMaxWidth; } // Remove modified transition on final sticky styles if (stickyStyles.transition) { if ('top' === ETScriptWindowStore.scrollLocation) { // top transition should remain removed when scroll location is on top window because // sticky element on top relies to `top` being updated as top window is scrolled finalStickyStyle.transition = trimTransitionValue(cssTransitions, ['top']); } else { finalStickyStyle.transition = ''; } } // `top` and `bottom` is building block of sticky element which is used to retain position // of module when entering sticky state. Thus `position: relative`'s vertical offset which // affects `top` and `bottom` needs to be intentionally modifies final `top` and `left` // property on the inline style if (stickyRelativeOrigin) { const relativePositionVerticalOffset = this.getRelativePositionOffset('vertical'); if (isNumber(stickyStyles.top)) { finalStickyStyle.top = `${stickyStyles.top + relativePositionVerticalOffset}px`; } if (isNumber(stickyStyles.bottom)) { finalStickyStyle.bottom = `${stickyStyles.bottom + relativePositionVerticalOffset}px`; } } this.getProp('$selector').css(finalStickyStyle); }, 50); } // Toggle background-layout classname of current module and/or its child modules toggleAllBackgroundLayoutClassnameOnSticky(this.getProp('$selector'), true); } /** * Set properties and inline styling that activate pause mode. Pause is invoked when sticky * element moves passed its limit. * * @since 4.6.0 */ pauseSticky = () => { const topLimit = this.getProp('topLimitSettings'); const bottomLimit = this.getProp('bottomLimitSettings'); const pauseStyle = {}; const heightSticky = this.getProp('heightSticky'); if (this.isStickyScroll('bottom') && topLimit) { // this.getProp('pauseScrollTop') is essentially equivalent to ETScriptWindowStore.scrollTop value // when the limit is passed. However it can't be used because pause might be called when // page load before any scroll event performed. Thus, manually calculate equivalent of window // scrollTop location when limit is passed this.setProp('pauseScrollTop', get(topLimit, 'offsets.top', 0) - (ETScriptWindowStore.height - (heightSticky + this.getOffset('bottom')))); const marginBottom = ETScriptWindowStore.scrollTop - this.getProp('pauseScrollTop') - this.getOffset('top', 'surrounding'); // Set sticky margin style as important setImportantInlineValue(this.getProp('$selector'), 'margin-bottom', `${marginBottom}px`); } else if (this.isStickyScroll('top') && bottomLimit) { // this.getProp('pauseScrollTop') is essentially equivalent to ETScriptWindowStore.scrollTop value // when the limit is passed. However it can't be used because pause might be called when // page load before any scroll event performed. Thus, manually calculate equivalent of window // scrollTop location when limit is passed this.setProp('pauseScrollTop', get(bottomLimit, 'offsets.bottom', 0) - (heightSticky + this.getOffset('top'))); const marginTop = ETScriptWindowStore.scrollTop - this.getProp('pauseScrollTop') + this.getOffset('bottom', 'surrounding'); // Set sticky margin style as important setImportantInlineValue(this.getProp('$selector'), 'margin-top', `${marginTop}px`); } } /** * Unset properties and inline styling which deactivate pause mode. Resume is performed when * paused sticky element is scrolled back from its limit. * * @since 4.6.0 */ resumeSticky = () => { const topLimit = this.getProp('topLimitSettings'); const bottomLimit = this.getProp('bottomLimitSettings'); const resumeStyle = {}; if (this.isStickyScroll('bottom') && topLimit) { resumeStyle.marginBottom = '0px'; } else if (this.isStickyScroll('top') && bottomLimit) { resumeStyle.marginTop = '0px'; } this.setProp('pauseScrollTop', false); this.getProp('$selector').css(resumeStyle); } /** * End sticky state. * * @since 4.6.0 */ endSticky = () => { // Lock parent heigth because sticky style with transition which modifies module's height takes // ms to be fully completed; This gap, if the sticky module is the only module of its parent, // potentially causes the parent height to grow for miliseconds before it shrink back this.lockParentHeight(); this.getPlaceholder().remove(); // Remove sticky element classnames if (! this.getProp('$selector').hasClass('et_pb_sticky--editing')) { this.getProp('$selector').removeClass(`et_pb_sticky et_pb_sticky--${this.getProp('position')}`); } // Clear stickyStart timeout to avoid inline style being added after exiting sticky state clearTimeout(this.startStickyFinalStyleTimeout); // Remove classname to builder wrapper to reset its stacking order this.toggleAffectingParentsClassname(false); // Dispatch custom event which frontend script can listen and react to window.dispatchEvent(new CustomEvent('ETBuilderStickyEnd', { detail: { stickyId: this.getProp('id'), }, })); // Style to remove inline sticky style const stickyStyles = { position: '', top: '', left: '', bottom: '', zIndex: '', width: '', marginTop: '', marginRight: '', marginBottom: '', marginLeft: '', 'max-width': '', 'will-change': '', padding: '', }; // Inline style to be added when module transition is completed const finalStickyStyles = {}; // Modify sticky styles and prepare for final sticky styles if current module uses position: relative const stickyRelativeOrigin = this.getSetting('stickyStyles.position_origin_r'); if (stickyRelativeOrigin) { const originVertical = isString(stickyRelativeOrigin) ? stickyRelativeOrigin.split('_')[0] : false; const originHorizontal = isString(stickyRelativeOrigin) ? stickyRelativeOrigin.split('_')[1] : false; const verticalOffset = this.getPropertyValueInPx('stickyStyles.vertical_offset'); const horizontalOffset = this.getPropertyValueInPx('stickyStyles.horizontal_offset'); const hasVerticalOffset = hasValue(verticalOffset); const hasHorizontalOffset = hasValue(horizontalOffset); // Only if there's value to avoid jumping layout when exiting sticky state. // Immediately reset inline style that is added during endSticky. if (hasVerticalOffset) { stickyStyles[originVertical] = isNumber(verticalOffset) ? `${verticalOffset}px` : verticalOffset; finalStickyStyles[originVertical] = ''; } if (hasHorizontalOffset) { stickyStyles[originHorizontal] = isNumber(horizontalOffset) ? `${horizontalOffset}px` : horizontalOffset; finalStickyStyles[originHorizontal] = ''; } if (hasVerticalOffset || hasHorizontalOffset) { // Remove `position: relative` offset related property (`top`, `right`, `bottom`, `left`) // from transition declaration to ensure smooth transition stickyStyles.transition = trimTransitionValue(this.getProp('$selector').css('transition'), ['top', 'right', 'bottom', 'left']); // Reset inline style that is added during endSticky finalStickyStyles.transition = ''; } } // Unset inline styles which deactivates sticky element this.getProp('$selector').css(stickyStyles); // Toggle background-layout classname of current module and/or its child modules toggleAllBackgroundLayoutClassnameOnSticky(this.getProp('$selector'), false); // Get timeout value based on module's transition-duration style let transitionDuration = parseFloat(this.getProp('$selector').css('transition-duration')) * 1000; if (! isNumber(transitionDuration)) { transitionDuration = 0; } clearTimeout(this.endStickyUnlockParentTimeout); this.endStickyUnlockParentTimeout = setTimeout(() => { this.unlockParentHeight(); // Apply final sticky styles if (! isEmpty(finalStickyStyles)) { this.getProp('$selector').css(finalStickyStyles); } // Reinitialized props if needed if (this.resetInitialPropsOnStickyEnd) { this.setInitialProps(); this.resetInitialPropsOnStickyEnd = false; } }, transitionDuration); } /** * Set fixed height for sticky module parent to avoid jump when sticky module is transitioned * from / to sticky style. * * @since 4.6.0 */ lockParentHeight = () => { const $parent = this.getProp('$selector').parent(); const $grandParent = $parent.parent(); const prefixClass = $grandParent.is('.et-l') ? `.${$grandParent.attr('class').replace(/ /g, '.')} ` : ''; const classBlocklist = ['has_et_pb_sticky', '']; const classRaw = $parent.attr('class'); const classList = classRaw ? classRaw.split(' ') : []; const classFiltered = filter(classList, className => ! includes(classBlocklist, className)); const className = `${prefixClass}.${classFiltered.join('.')}`; const parentHeight = $parent.outerHeight(); const lockDeclaration = `${className} {height: ${parentHeight}px !important;}`; const $heightLock = $(``); // Start by unlocking existing (if there's any) to avoid duplicated lockHeight style this.unlockParentHeight(); this.getProp('$selector').append($heightLock); } /** * Remove on-page