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