diff --git a/packages/overlayscrollbars/src/lifecycles/lifecycleHub.ts b/packages/overlayscrollbars/src/lifecycles/lifecycleHub.ts index b1432d3..0b25eb8 100644 --- a/packages/overlayscrollbars/src/lifecycles/lifecycleHub.ts +++ b/packages/overlayscrollbars/src/lifecycles/lifecycleHub.ts @@ -1,28 +1,11 @@ -import { - XY, - WH, - TRBL, - CacheValues, - PartialOptions, - each, - hasOwnProperty, - isNumber, - scrollLeft, - scrollTop, - assignDeep, - liesBetween, - diffClass, -} from 'support'; +import { XY, WH, TRBL, CacheValues, PartialOptions, each, hasOwnProperty, isNumber, scrollLeft, scrollTop, assignDeep } from 'support'; import { OSOptions } from 'options'; -import { classNameHost, classNameViewport, classNameContent } from 'classnames'; import { getEnvironment } from 'environment'; import { StructureSetup } from 'setups/structureSetup'; +import { lifecycleHubOservers } from 'lifecycles/lifecycleHubObservers'; import { createTrinsicLifecycle } from 'lifecycles/trinsicLifecycle'; import { createPaddingLifecycle } from 'lifecycles/paddingLifecycle'; import { createOverflowLifecycle } from 'lifecycles/overflowLifecycle'; -import { createSizeObserver } from 'observers/sizeObserver'; -import { createTrinsicObserver } from 'observers/trinsicObserver'; -import { createDOMObserver } from 'observers/domObserver'; import { StyleObject } from 'typings'; export type LifecycleCheckOption = (path: string) => LifecycleOptionInfo; @@ -82,28 +65,7 @@ export interface LifecycleHub { const getPropByPath = (obj: any, path: string): T => obj ? path.split('.').reduce((o, prop) => (o && hasOwnProperty(o, prop) ? o[prop] : undefined), obj) : undefined; -// TODO: observer textarea attrs if textarea -// TODO: tabindex, open etc. -// TODO: test _ignoreContentChange & _ignoreNestedTargetChange for content dom observer -// TODO: test _ignoreTargetChange for target dom observer -const ignorePrefix = 'os-'; -const hostSelector = `.${classNameHost}`; -const viewportSelector = `.${classNameViewport}`; -const contentSelector = `.${classNameContent}`; -const attrs = ['id', 'class', 'style', 'open']; -const ignoreTargetChange = (target: Node, attrName: string, oldValue: string | null, newValue: string | null) => { - if (attrName === 'class' && oldValue && newValue) { - const diff = diffClass(oldValue, newValue); - return !!diff.find((addedOrRemovedClass) => addedOrRemovedClass.indexOf(ignorePrefix) !== 0); - } - return false; -}; -const directionIsRTLCacheValuesFallback: CacheValues = { - _value: false, - _previous: false, - _changed: false, -}; -const heightIntrinsicCacheValuesFallback: CacheValues = { +const booleanCacheValuesFallback: CacheValues = { _value: false, _previous: false, _changed: false, @@ -139,7 +101,7 @@ const lifecycleCommunicationFallback: LifecycleCommunication = { export const createLifecycleHub = (options: OSOptions, structureSetup: StructureSetup): LifecycleHubInstance => { let lifecycleCommunication = lifecycleCommunicationFallback; - const { _host, _viewport, _content } = structureSetup._targetObj; + const { _viewport } = structureSetup._targetObj; const { _nativeScrollbarStyling, _nativeScrollbarIsOverlaid, @@ -168,10 +130,11 @@ export const createLifecycleHub = (options: OSOptions, structureSetup: Structure _contentMutation = force || false, _paddingStyleChanged = force || false, } = updateHints || {}; + const finalDirectionIsRTL = - _directionIsRTL || (sizeObserver ? sizeObserver._getCurrentCacheValues(force)._directionIsRTL : directionIsRTLCacheValuesFallback); + _directionIsRTL || (_sizeObserver ? _sizeObserver._getCurrentCacheValues(force)._directionIsRTL : booleanCacheValuesFallback); const finalHeightIntrinsic = - _heightIntrinsic || (trinsicObserver ? trinsicObserver._getCurrentCacheValues(force)._heightIntrinsic : heightIntrinsicCacheValuesFallback); + _heightIntrinsic || (_trinsicObserver ? _trinsicObserver._getCurrentCacheValues(force)._heightIntrinsic : booleanCacheValuesFallback); const checkOption: LifecycleCheckOption = (path) => ({ _value: getPropByPath(options, path), _changed: force || getPropByPath(changedOptions, path) !== undefined, @@ -180,6 +143,11 @@ export const createLifecycleHub = (options: OSOptions, structureSetup: Structure const scrollOffsetX = adjustScrollOffset && scrollLeft(_viewport); const scrollOffsetY = adjustScrollOffset && scrollTop(_viewport); + // place before updating lifecycles because of possible flushing of debounce + if (_updateObserverOptions) { + _updateObserverOptions(checkOption); + } + each(lifecycles, (lifecycle) => { const { _sizeChanged: adaptiveSizeChanged, @@ -217,58 +185,7 @@ export const createLifecycleHub = (options: OSOptions, structureSetup: Structure options.callbacks.onUpdated(); } }; - - const onSizeChanged = (directionIsRTL?: CacheValues) => { - const sizeChanged = !directionIsRTL; - updateLifecycles({ - _directionIsRTL: directionIsRTL, - _sizeChanged: sizeChanged, - }); - }; - const onTrinsicChanged = (heightIntrinsic: CacheValues) => { - updateLifecycles({ - _heightIntrinsic: heightIntrinsic, - }); - }; - const onHostMutation = () => { - // TODO: rAF only here because IE - requestAnimationFrame(() => { - updateLifecycles({ - _hostMutation: true, - }); - }); - }; - const onContentMutation = () => { - // TODO: rAF only here because IE - requestAnimationFrame(() => { - updateLifecycles({ - _contentMutation: true, - }); - }); - }; - - const trinsicObserver = (_content || !_flexboxGlue) && createTrinsicObserver(_host, onTrinsicChanged); - const sizeObserver = createSizeObserver(_host, onSizeChanged, { _appear: true, _direction: !_nativeScrollbarStyling }); - const hostMutationObserver = createDOMObserver(_host, false, onHostMutation, { - _styleChangingAttributes: attrs, - _attributes: attrs, - _ignoreTargetChange: ignoreTargetChange, - }); - const contentMutationObserver = createDOMObserver(_content || _viewport, true, onContentMutation, { - _styleChangingAttributes: attrs, - _attributes: attrs, - _eventContentChange: options!.updating!.elementEvents, - _nestedTargetSelector: hostSelector, - _ignoreContentChange: (mutation, isNestedTarget) => { - const { target, attributeName } = mutation; - return isNestedTarget - ? false - : attributeName - ? liesBetween(target as Element, hostSelector, viewportSelector) || liesBetween(target as Element, hostSelector, contentSelector) - : false; - }, - _ignoreNestedTargetChange: ignoreTargetChange, - }); + const { _sizeObserver, _trinsicObserver, _updateObserverOptions } = lifecycleHubOservers(instance, updateLifecycles); const update = (changedOptions?: Partial | null, force?: boolean) => { updateLifecycles(null, changedOptions, force); diff --git a/packages/overlayscrollbars/src/lifecycles/lifecycleHubObservers.ts b/packages/overlayscrollbars/src/lifecycles/lifecycleHubObservers.ts new file mode 100644 index 0000000..9b13dd0 --- /dev/null +++ b/packages/overlayscrollbars/src/lifecycles/lifecycleHubObservers.ts @@ -0,0 +1,141 @@ +import { CacheValues, diffClass, debounce, isArray, isNumber } from 'support'; +import { getEnvironment } from 'environment'; +import { createSizeObserver, SizeObserverCallbackParams } from 'observers/sizeObserver'; +import { createTrinsicObserver } from 'observers/trinsicObserver'; +import { createDOMObserver, DOMObserver } from 'observers/domObserver'; +import { LifecycleHub, LifecycleCheckOption, LifecycleUpdateHints } from 'lifecycles/lifecycleHub'; + +//const hostSelector = `.${classNameHost}`; + +// TODO: observer textarea attrs if textarea +// TODO: tabindex, etc. attributes for viewport +// TODO: test _ignoreContentChange & _ignoreNestedTargetChange for content dom observer +// TODO: test _ignoreTargetChange for target dom observer + +//const viewportSelector = `.${classNameViewport}`; +//const contentSelector = `.${classNameContent}`; +const ignorePrefix = 'os-'; +const viewportAttrsFromTarget = ['tabindex']; +const baseStyleChangingAttrsTextarea = ['wrap', 'cols', 'rows']; +const baseStyleChangingAttrs = ['id', 'class', 'style', 'open']; + +const ignoreTargetChange = (target: Node, attrName: string, oldValue: string | null, newValue: string | null) => { + if (attrName === 'class' && oldValue && newValue) { + const diff = diffClass(oldValue, newValue); + return !!diff.find((addedOrRemovedClass) => addedOrRemovedClass.indexOf(ignorePrefix) !== 0); + } + return false; +}; + +export const lifecycleHubOservers = (instance: LifecycleHub, updateLifecycles: (updateHints?: Partial | null) => unknown) => { + let debounceTimeout: number | false | undefined; + let debounceMaxDelay: number | false | undefined; + const { _structureSetup } = instance; + const { _targetObj, _targetCtx } = _structureSetup; + const { _host, _viewport, _content } = _targetObj; + const { _isTextarea } = _targetCtx; + const { _nativeScrollbarStyling, _flexboxGlue } = getEnvironment(); + const contentMutationObserverAttr = _isTextarea ? baseStyleChangingAttrsTextarea : baseStyleChangingAttrs.concat(baseStyleChangingAttrsTextarea); + const updateLifecyclesWithDebouncedAdaptiveUpdateHints = debounce(updateLifecycles as (updateHints: Partial) => any, { + _timeout: () => debounceTimeout, + _maxDelay: () => debounceMaxDelay, + _mergeParams(prev, curr) { + const { _sizeChanged: prevSizeChanged, _hostMutation: prevHostMutation, _contentMutation: prevContentMutation } = prev[0]; + const { _sizeChanged: currSizeChanged, _hostMutation: currvHostMutation, _contentMutation: currContentMutation } = curr[0]; + const merged: [Partial] = [ + { + _sizeChanged: prevSizeChanged || currSizeChanged, + _hostMutation: prevHostMutation || currvHostMutation, + _contentMutation: prevContentMutation || currContentMutation, + }, + ]; + + return merged; + }, + }); + + const onTrinsicChanged = (heightIntrinsic: CacheValues) => { + updateLifecycles({ + _heightIntrinsic: heightIntrinsic, + }); + }; + const onSizeChanged = ({ _sizeChanged, _directionIsRTLCache, _appear }: SizeObserverCallbackParams) => { + const updateFn = !_sizeChanged || _appear ? updateLifecycles : updateLifecyclesWithDebouncedAdaptiveUpdateHints; + updateFn({ + _sizeChanged, + _directionIsRTL: _directionIsRTLCache, + }); + }; + const onContentMutation = (contentChangedTroughEvent: boolean) => { + // if contentChangedTroughEvent is true its already debounced + const updateFn = contentChangedTroughEvent ? updateLifecycles : updateLifecyclesWithDebouncedAdaptiveUpdateHints; + updateFn({ + _contentMutation: true, + }); + }; + const onHostMutation = updateLifecyclesWithDebouncedAdaptiveUpdateHints.bind(0, { + _hostMutation: true, + }) as () => any; + + const trinsicObserver = (_content || !_flexboxGlue) && createTrinsicObserver(_host, onTrinsicChanged); + const sizeObserver = createSizeObserver(_host, onSizeChanged, { _appear: true, _direction: !_nativeScrollbarStyling }); + const hostMutationObserver = createDOMObserver(_host, false, onHostMutation, { + _styleChangingAttributes: baseStyleChangingAttrs, + _attributes: baseStyleChangingAttrs, + _ignoreTargetChange: ignoreTargetChange, + }); + let contentMutationObserver: DOMObserver | undefined; + + const updateOptions = (checkOption: LifecycleCheckOption) => { + const { _value: elementEvents, _changed: elementEventsChanged } = checkOption | null>('updating.elementEvents'); + const { _value: attributes, _changed: attributesChanged } = checkOption('updating.attributes'); + const { _value: debounce, _changed: debounceChanged } = checkOption | number | null>('updating.debounce'); + const updateContentMutationObserver = elementEventsChanged || attributesChanged; + + if (updateContentMutationObserver) { + if (contentMutationObserver) { + contentMutationObserver._update(); + contentMutationObserver._destroy(); + } + contentMutationObserver = createDOMObserver(_content || _viewport, true, onContentMutation, { + _styleChangingAttributes: contentMutationObserverAttr.concat(attributes || []), + _attributes: contentMutationObserverAttr.concat(attributes || []), + _eventContentChange: elementEvents, + _ignoreNestedTargetChange: ignoreTargetChange, + //_nestedTargetSelector: hostSelector, + /* + _ignoreContentChange: (mutation, isNestedTarget) => { + const { target, attributeName } = mutation; + return isNestedTarget + ? false + : attributeName + ? liesBetween(target as Element, hostSelector, viewportSelector) || liesBetween(target as Element, hostSelector, contentSelector) + : false; + }, + */ + }); + } + + if (debounceChanged) { + updateLifecyclesWithDebouncedAdaptiveUpdateHints._flush(); + if (isArray(debounce)) { + const timeout = debounce[0]; + const maxWait = debounce[1]; + debounceTimeout = isNumber(timeout) ? timeout : false; + debounceMaxDelay = isNumber(maxWait) ? maxWait : false; + } else if (isNumber(debounce)) { + debounceTimeout = debounce; + debounceMaxDelay = false; + } else { + debounceTimeout = false; + debounceMaxDelay = false; + } + } + }; + + return { + _trinsicObserver: trinsicObserver, + _sizeObserver: sizeObserver, + _updateObserverOptions: updateOptions, + }; +}; diff --git a/packages/overlayscrollbars/src/observers/domObserver.ts b/packages/overlayscrollbars/src/observers/domObserver.ts index 6674f4a..fc826a3 100644 --- a/packages/overlayscrollbars/src/observers/domObserver.ts +++ b/packages/overlayscrollbars/src/observers/domObserver.ts @@ -13,12 +13,11 @@ import { find, push, isUndefined, - isFunction, } from 'support'; type StringNullUndefined = string | null | undefined; -type DOMContentObserverCallback = (contentChanged: boolean) => any; +type DOMContentObserverCallback = (contentChangedTroughEvent: boolean) => any; type DOMTargetObserverCallback = (targetChangedAttrs: string[], targetStyleChanged: boolean) => any; @@ -38,18 +37,7 @@ interface DOMTargetObserverOptions extends DOMObserverOptionsBase { _ignoreTargetChange?: DOMObserverIgnoreTargetChange; // a function which will prevent marking certain attributes as changed if it returns true } -interface DOMObserverBase { - _destroy: () => void; - _update: () => void; -} - -interface DOMContentObserver extends DOMObserverBase { - _updateEventContentChange: (newEventContentChange?: DOMObserverEventContentChange) => void; -} - -interface DOMTargetObserver extends DOMObserverBase {} - -type ContentChangeArrayItem = [StringNullUndefined, ((elms: Node[]) => StringNullUndefined) | StringNullUndefined] | null | undefined; +type ContentChangeArrayItem = [StringNullUndefined, StringNullUndefined] | null | undefined; export type DOMObserverEventContentChange = Array | false | null | undefined; @@ -57,7 +45,7 @@ export type DOMObserverIgnoreContentChange = ( mutation: MutationRecord, isNestedTarget: boolean, domObserverTarget: HTMLElement, - domObserverOptions: DOMContentObserverOptions | undefined + domObserverOptions?: DOMContentObserverOptions ) => boolean; export type DOMObserverIgnoreTargetChange = ( @@ -73,10 +61,10 @@ export type DOMObserverCallback = ContentObserv export type DOMObserverOptions = ContentObserver extends true ? DOMContentObserverOptions : DOMTargetObserverOptions; -export type DOMObserver = ContentObserver extends true ? DOMContentObserver : DOMTargetObserver; - -// const styleChangingAttributes = ['id', 'class', 'style', 'open']; -// const mutationObserverAttrsTextarea = ['wrap', 'cols', 'rows']; +export interface DOMObserver { + _destroy: () => void; + _update: () => void; +} /** * Creates a set of helper functions to observe events of elements inside the target element. @@ -87,7 +75,6 @@ export type DOMObserver = ContentObserver exten */ const createEventContentChange = (target: Element, eventContentChange: DOMObserverEventContentChange, callback: (...args: any) => any) => { let map: Map | undefined; - let eventContentChangeRef: DOMObserverEventContentChange; const _destroy = () => { if (map) { map.forEach((eventName: string, elm: Node) => off(elm, eventName, callback)); @@ -95,16 +82,15 @@ const createEventContentChange = (target: Element, eventContentChange: DOMObserv } }; const _updateElements = (getElements?: (selector: string) => Node[]) => { - if (map && eventContentChangeRef) { - const eventElmList = eventContentChangeRef.reduce>((arr, item) => { + if (map && eventContentChange) { + const eventElmList = eventContentChange.reduce>((arr, item) => { if (item) { const selector = item[0]; const eventNames = item[1]; const elements = eventNames && selector && (getElements ? getElements(selector) : find(selector, target)); - const parsedEventNames = isFunction(eventNames) ? eventNames(elements) : eventNames; - if (elements && elements.length && parsedEventNames && isString(parsedEventNames)) { - push(arr, [elements, parsedEventNames.trim()], true); + if (elements && elements.length && eventNames && isString(eventNames)) { + push(arr, [elements, eventNames.trim()], true); } } return arr; @@ -128,21 +114,16 @@ const createEventContentChange = (target: Element, eventContentChange: DOMObserv ); } }; - const _updateEventContentChange = (newEventContentChange: DOMObserverEventContentChange) => { - map = map || new Map(); - eventContentChangeRef = newEventContentChange; - _destroy(); - _updateElements(); - }; if (eventContentChange) { - _updateEventContentChange(eventContentChange); + map = map || new Map(); + _destroy(); + _updateElements(); } return { _destroy, _updateElements, - _updateEventContentChange, }; }; @@ -159,7 +140,7 @@ export const createDOMObserver = ( isContentObserver: ContentObserver, callback: DOMObserverCallback, options?: DOMObserverOptions -): DOMObserver => { +): DOMObserver => { let isConnected = false; const { _attributes, @@ -170,18 +151,17 @@ export const createDOMObserver = ( _ignoreNestedTargetChange, _ignoreContentChange, } = (options as DOMContentObserverOptions & DOMTargetObserverOptions) || {}; - const { - _destroy: destroyEventContentChange, - _updateElements: updateEventContentChangeElements, - _updateEventContentChange: updateEventContentChange, - } = createEventContentChange( + const { _destroy: destroyEventContentChange, _updateElements: updateEventContentChangeElements } = createEventContentChange( target, isContentObserver && _eventContentChange, - debounce(() => { - if (isConnected) { - (callback as DOMContentObserverCallback)(true); - } - }, 84) + debounce( + () => { + if (isConnected) { + (callback as DOMContentObserverCallback)(true); + } + }, + { _timeout: 33, _maxDelay: 99 } + ) ); // MutationObserver @@ -243,7 +223,7 @@ export const createDOMObserver = ( } if (isContentObserver) { - contentChanged && (callback as DOMContentObserverCallback)(contentChanged); + contentChanged && (callback as DOMContentObserverCallback)(false); } else if (!isEmptyArray(targetChangedAttrs) || targetStyleChanged) { (callback as DOMTargetObserverCallback)(targetChangedAttrs, targetStyleChanged); } @@ -269,13 +249,10 @@ export const createDOMObserver = ( isConnected = false; } }, - _updateEventContentChange: (newEventContentChange?: DOMObserverEventContentChange) => { - updateEventContentChange(isConnected && isContentObserver && newEventContentChange); - }, _update: () => { if (isConnected) { observerCallback(mutationObserver.takeRecords()); } }, - } as DOMObserver; + }; }; diff --git a/packages/overlayscrollbars/src/observers/sizeObserver.ts b/packages/overlayscrollbars/src/observers/sizeObserver.ts index 882fa9c..b8d7dd4 100644 --- a/packages/overlayscrollbars/src/observers/sizeObserver.ts +++ b/packages/overlayscrollbars/src/observers/sizeObserver.ts @@ -33,7 +33,16 @@ import { classNameSizeObserverListenerItemFinal, } from 'classnames'; -export type SizeObserverOptions = { _direction?: boolean; _appear?: boolean }; +export interface SizeObserverOptions { + _direction?: boolean; + _appear?: boolean; +} + +export interface SizeObserverCallbackParams { + _sizeChanged: boolean; + _directionIsRTLCache?: CacheValues; + _appear?: boolean; +} export interface SizeObserver { _destroy(): void; @@ -73,7 +82,7 @@ const domRectHasDimensions = (rect?: DOMRectReadOnly) => rect && (rect.height || */ export const createSizeObserver = ( target: HTMLElement, - onSizeChangedCallback: (directionIsRTLCache?: CacheValues) => any, + onSizeChangedCallback: (params: SizeObserverCallbackParams) => any, options?: SizeObserverOptions ): SizeObserver => { const { _direction: observeDirectionChange = false, _appear: observeAppearChange = false } = options || {}; @@ -90,31 +99,44 @@ export const createSizeObserver = ( (!domRectHasDimensions(currVal) && domRectHasDimensions(newVal)) ), }); - const onSizeChangedCallbackProxy = (sizeChangedContext?: CacheValues | ResizeObserverEntry[] | Event) => { + const onSizeChangedCallbackProxy = (sizeChangedContext?: CacheValues | ResizeObserverEntry[] | Event | boolean) => { const hasDirectionCache = sizeChangedContext && isBoolean((sizeChangedContext as CacheValues)._value); let skip = false; + let appear: boolean | number | undefined = false; let doDirectionScroll = true; // always true if sizeChangedContext is Event (appear callback or RO. Polyfill) // if triggered from RO. if (isArray(sizeChangedContext) && sizeChangedContext.length > 0) { - const { _previous, _value, _changed } = updateResizeObserverContentRectCache(0, sizeChangedContext.pop()!.contentRect); - skip = !_previous || !domRectHasDimensions(_value); // skip on initial RO. call or if display is none - doDirectionScroll = !skip && _changed; // direction scroll when not skipping and changing from display: none to block, false otherwise + const { _previous, _value } = updateResizeObserverContentRectCache(0, sizeChangedContext.pop()!.contentRect); + const hasDimensions = domRectHasDimensions(_value); + const hadDimensions = domRectHasDimensions(_previous); + skip = !_previous || !hasDimensions; // skip on initial RO. call or if display is none + appear = !hadDimensions && hasDimensions; + + doDirectionScroll = !skip; // direction scroll when not skipping } // else if its triggered with DirectionCache else if (hasDirectionCache) { doDirectionScroll = (sizeChangedContext as CacheValues)._changed; // direction scroll when DirectionCache changed, false otherwise } + // else if it triggered with appear from polyfill + else { + appear = sizeChangedContext === true; + } - if (observeDirectionChange) { + if (observeDirectionChange && doDirectionScroll) { const rtl = hasDirectionCache ? (sizeChangedContext as CacheValues)._value : directionIsRTL(sizeObserver); scrollLeft(sizeObserver, rtl ? (rtlScrollBehavior.n ? -scrollAmount : rtlScrollBehavior.i ? 0 : scrollAmount) : scrollAmount); scrollTop(sizeObserver, scrollAmount); } if (!skip) { - onSizeChangedCallback(hasDirectionCache ? (sizeChangedContext as CacheValues) : undefined); + onSizeChangedCallback({ + _sizeChanged: !hasDirectionCache, + _directionIsRTLCache: hasDirectionCache ? (sizeChangedContext as CacheValues) : undefined, + _appear: !!appear, + }); } }; const offListeners: (() => void)[] = []; @@ -147,11 +169,11 @@ export const createSizeObserver = ( scrollLeft(shrinkElement, scrollAmount); scrollTop(shrinkElement, scrollAmount); }; - const onResized = () => { + const onResized = (appear?: unknown) => { rAFId = 0; if (isDirty) { cacheSize = currSize; - onSizeChangedCallbackProxy(); + onSizeChangedCallbackProxy(appear === true); } }; const onScroll = (scrollEvent?: Event | false) => { @@ -166,7 +188,7 @@ export const createSizeObserver = ( rAFId = rAF!(onResized); } } else { - onResized(); + onResized(scrollEvent === false); } reset(); diff --git a/packages/overlayscrollbars/src/options.ts b/packages/overlayscrollbars/src/options.ts index 0ca69f7..5c77b14 100644 --- a/packages/overlayscrollbars/src/options.ts +++ b/packages/overlayscrollbars/src/options.ts @@ -35,9 +35,8 @@ export interface OSOptions { paddingAbsolute: boolean; updating: { elementEvents: Array<[string, string]> | null; - contentMutationDebounce: number; - hostMutationDebounce: number; - resizeDebounce: number; + attributes: string[] | null; + debounce: number | [number, number] | null; }; overflow: { x: OverflowBehavior; @@ -109,6 +108,7 @@ export interface UpdatedArgs { } const numberAllowedValues: OptionsTemplateValue = oTypes.number; +const arrayNullValues: OptionsTemplateValue | null> = [oTypes.array, oTypes.null]; const stringArrayNullAllowedValues: OptionsTemplateValue | null> = [oTypes.string, oTypes.array, oTypes.null]; const booleanTrueTemplate: OptionsWithOptionsTemplateValue = [true, oTypes.boolean]; const booleanFalseTemplate: OptionsWithOptionsTemplateValue = [false, oTypes.boolean]; @@ -137,10 +137,12 @@ const defaultOptionsWithTemplate: OptionsWithOptionsTemplate = { resize: ['none', resizeAllowedValues], // none || both || horizontal || vertical || n || b || h || v paddingAbsolute: booleanFalseTemplate, // true || false updating: { - elementEvents: [[['img', 'load']], [oTypes.array, oTypes.null]], // array of tuples || null - contentMutationDebounce: [80, numberAllowedValues], // number - hostMutationDebounce: [0, numberAllowedValues], // number - resizeDebounce: [0, numberAllowedValues], // number + elementEvents: [[['img', 'load']], arrayNullValues], // array of tuples || null + attributes: [null, arrayNullValues], + debounce: [ + [0, 33], + [oTypes.number, oTypes.array, oTypes.null], + ], // number || number array || null }, overflow: { x: ['scroll', overflowAllowedValues], // visible-hidden || visible-scroll || hidden || scroll || v-h || v-s || h || s diff --git a/packages/overlayscrollbars/src/support/utils/function.ts b/packages/overlayscrollbars/src/support/utils/function.ts index c5d7f8f..d0925ad 100644 --- a/packages/overlayscrollbars/src/support/utils/function.ts +++ b/packages/overlayscrollbars/src/support/utils/function.ts @@ -1,36 +1,103 @@ -import { isNumber } from 'support/utils/types'; -import { cAF, rAF } from 'support/compatibility/apis'; +import { isNumber, isFunction } from 'support/utils/types'; +import { rAF, cAF } from 'support/compatibility/apis'; + +const setT = window.setTimeout; +const clearTimeouts = (id: number | undefined) => { + id && window.clearTimeout(id); + id && cAF!(id); +}; + +type DebounceTiming = number | false | null | undefined; + +export interface DebounceOptions any> { + /** + * The timeout for debouncing. If null, no debounce is applied. + */ + _timeout?: DebounceTiming | (() => DebounceTiming); + /** + * A maximum amount of ms. before the function will be called even with debounce. + */ + _maxDelay?: DebounceTiming | (() => DebounceTiming); + /** + * Function which merges parameters for each canceled debounce. + * If parameters can't be merged the function will return null, otherwise it returns the merged parameters. + */ + _mergeParams?: ( + prev: Parameters, + curr: Parameters + ) => Parameters | false | null | undefined; +} + +export interface Debounced any> { + (...args: Parameters): ReturnType; + _flush(): void; +} export const noop = () => {}; // eslint-disable-line /** * Debounces the given function either with a timeout or a animation frame. * @param functionToDebounce The function which shall be debounced. - * @param timeout The timeout for debouncing. If 0 or lower animation frame is used for debouncing, a timeout otherwise. - * @param maxWait A maximum amount of ms. before the function will be called even with debounce. + * @param options Options for debouncing. */ -export const debounce = (functionToDebounce: (...args: any) => any, timeout?: number, maxWait?: number) => { - let timeoutId: number | void; - let lastCallTime: number; - const hasTimeout = isNumber(timeout) && timeout > 0; - const hasMaxWait = isNumber(maxWait) && maxWait > 0; - const cancel = hasTimeout ? window.clearTimeout : cAF!; - const set = hasTimeout ? window.setTimeout : rAF!; - const setFn = function (args: IArguments) { - lastCallTime = hasMaxWait ? performance.now() : 0; - timeoutId && cancel(timeoutId); +export const debounce = any>( + functionToDebounce: FunctionToDebounce, + options: DebounceOptions +): Debounced => { + let timeoutId: number | undefined; + let maxTimeoutId: number | undefined; + let prevArguments: Parameters | null | undefined; + let latestArguments: Parameters | null | undefined; + const { _timeout, _maxDelay, _mergeParams } = options; + + const invokeFunctionToDebounce = function (args: IArguments) { + clearTimeouts(timeoutId); + clearTimeouts(maxTimeoutId); + maxTimeoutId = timeoutId = prevArguments = undefined; // eslint-disable-next-line // @ts-ignore functionToDebounce.apply(this, args); }; - return function () { - // eslint-disable-next-line - // @ts-ignore - const boundSetFn = setFn.bind(this, arguments); // eslint-disable-line - const forceCall = hasMaxWait ? performance.now() - lastCallTime >= maxWait! : false; + const mergeParms = (curr: Parameters): Parameters | false | null | undefined => + _mergeParams && prevArguments ? _mergeParams(prevArguments, curr) : curr; - timeoutId && cancel(timeoutId); - timeoutId = forceCall ? boundSetFn() : (set(boundSetFn, timeout!) as number); + const flush = () => { + if (timeoutId) { + invokeFunctionToDebounce(mergeParms(latestArguments!) || latestArguments!); + } }; + + const debouncedFn = function () { + const args: Parameters = arguments as Parameters; + const finalTimeout = isFunction(_timeout) ? _timeout() : _timeout; + const hasTimeout = isNumber(finalTimeout) && finalTimeout >= 0; + + if (hasTimeout) { + const finalMaxWait = isFunction(_maxDelay) ? _maxDelay() : _maxDelay; + const hasMaxWait = isNumber(finalMaxWait) && finalMaxWait >= 0; + const setTimeoutFn = finalTimeout! > 0 ? setT : rAF!; + const mergeParamsResult = mergeParms(args); + const invokedArgs = mergeParamsResult || args; + const boundInvoke = invokeFunctionToDebounce.bind(0, invokedArgs); + + if (!mergeParamsResult) { + invokeFunctionToDebounce(prevArguments || args); + } + + clearTimeouts(timeoutId); + timeoutId = setTimeoutFn(boundInvoke, finalTimeout as number) as number; + + if (hasMaxWait && !maxTimeoutId) { + maxTimeoutId = setT(flush, finalMaxWait as number); + } + + prevArguments = latestArguments = invokedArgs; + } else { + invokeFunctionToDebounce(args); + } + }; + debouncedFn._flush = flush; + + return debouncedFn as Debounced; }; diff --git a/packages/overlayscrollbars/tests/browser/observers/domObserver/index.browser.ts b/packages/overlayscrollbars/tests/browser/observers/domObserver/index.browser.ts index 44ef84c..a9b31b1 100644 --- a/packages/overlayscrollbars/tests/browser/observers/domObserver/index.browser.ts +++ b/packages/overlayscrollbars/tests/browser/observers/domObserver/index.browser.ts @@ -4,24 +4,14 @@ import should from 'should'; import { generateSelectCallback, iterateSelect } from '@/testing-browser/Select'; import { timeout } from '@/testing-browser/timeout'; import { setTestResult, waitForOrFailTest } from '@/testing-browser/TestResult'; -import { - appendChildren, - createDiv, - removeElements, - children, - isArray, - isNumber, - liesBetween, - hasClass, - addClass, - removeClass, - diffClass, - on, -} from 'support'; +import { appendChildren, createDiv, removeElements, children, isArray, isNumber, liesBetween, addClass, removeClass, diffClass, on } from 'support'; import { createDOMObserver } from 'observers/domObserver'; -type DOMContentObserverResult = boolean; +type DOMContentObserverResult = { + contentChange: boolean; + troughEvent: boolean; +}; type DOMTargetObserverResult = { changedTargetAttrs: string[]; styleChanged: boolean; @@ -34,6 +24,7 @@ interface SeparateChangeThrough { const targetChangesCountSlot: HTMLElement | null = document.querySelector('#targetChanges'); const contentChangesCountSlot: HTMLElement | null = document.querySelector('#contentChanges'); const targetElm: HTMLElement | null = document.querySelector('#target'); +const trargetContentElm: HTMLElement | null = document.querySelector('#target .content'); const targetElmContentElm: HTMLElement | null = document.querySelector('#content-host'); const contentElmAttrChange: HTMLElement | null = document.querySelector('#target .content-nest'); const contentBetweenElmAttrChange: HTMLElement | null = document.querySelector('#content-host .padding-nest-item'); @@ -67,7 +58,7 @@ const startBtn: HTMLButtonElement | null = document.querySelector('#start'); const hostSelector = '.host'; const ignorePrefix = 'ignore'; const attrs = ['id', 'class', 'style', 'open']; -const contentChangeArr: Array<[string, string | ((elms: Node[]) => string)]> = [['img', 'load']]; +const contentChangeArr: Array<[string, string]> = [['img', 'load']]; const domTargetObserverObservations: DOMTargetObserverResult[] = []; const domContentObserverObservations: DOMContentObserverResult[] = []; @@ -115,44 +106,48 @@ const targetDomObserver = createDOMObserver( } ); -const contentDomObserver = createDOMObserver( - document.querySelector('#target .content')!, - true, - (contentChanged: boolean) => { - should.equal(typeof contentChanged, 'boolean', 'The contentChanged parameter in a content dom observer must be a boolean.'); +const createContentDomOserver = (eventContentChange: Array<[string | null | undefined, string | null | undefined] | null | undefined>) => { + return createDOMObserver( + trargetContentElm!, + true, + (contentChangedTroughEvent: boolean) => { + should.equal(typeof contentChangedTroughEvent, 'boolean', 'The contentChanged parameter in a content dom observer must be a boolean.'); - domContentObserverObservations.push(contentChanged); - requestAnimationFrame(() => { - if (contentChangesCountSlot) { - contentChangesCountSlot.textContent = `${domContentObserverObservations.length}`; - } - }); - }, - { - _styleChangingAttributes: attrs, - _attributes: attrs, - _eventContentChange: contentChangeArr, - _nestedTargetSelector: hostSelector, - _ignoreContentChange: (mutation, isNestedTarget) => { - const { target, attributeName } = mutation; - return isNestedTarget ? false : attributeName ? liesBetween(target as Element, hostSelector, '.content') : false; + domContentObserverObservations.push({ contentChange: true, troughEvent: contentChangedTroughEvent }); + requestAnimationFrame(() => { + if (contentChangesCountSlot) { + contentChangesCountSlot.textContent = `${domContentObserverObservations.length}`; + } + }); }, - _ignoreNestedTargetChange: (target, attrName, oldValue, newValue) => { - if (attrName === 'class' && oldValue && newValue) { - const diff = diffClass(oldValue, newValue); - const ignore = diff.length === 1 && diff[0].startsWith(ignorePrefix); - return ignore; - } - return false; - }, - // @ts-ignore - _ignoreTargetChange: () => { - // if param: isContentObserver = true, this function should never be called. - should.ok(false, 'A content dom observer must not call the _ignoreTargetChange method.'); - return true; - }, - } -); + { + _styleChangingAttributes: attrs, + _attributes: attrs, + _eventContentChange: eventContentChange, + _nestedTargetSelector: hostSelector, + _ignoreContentChange: (mutation, isNestedTarget) => { + const { target, attributeName } = mutation; + return isNestedTarget ? false : attributeName ? liesBetween(target as Element, hostSelector, '.content') : false; + }, + _ignoreNestedTargetChange: (target, attrName, oldValue, newValue) => { + if (attrName === 'class' && oldValue && newValue) { + const diff = diffClass(oldValue, newValue); + const ignore = diff.length === 1 && diff[0].startsWith(ignorePrefix); + return ignore; + } + return false; + }, + // @ts-ignore + _ignoreTargetChange: () => { + // if param: isContentObserver = true, this function should never be called. + should.ok(false, 'A content dom observer must not call the _ignoreTargetChange method.'); + return true; + }, + } + ); +}; + +let contentDomObserver = createContentDomOserver(contentChangeArr); const getTotalObservations = () => domTargetObserverObservations.length + domContentObserverObservations.length; const getLast = (arr: T[], indexFromLast = 0): T => arr[arr.length - 1 - indexFromLast] || ({} as T); @@ -277,7 +272,7 @@ const addRemoveElementsTest = async (slot: Element | null, changeThrough?: DOMCo if (addChangeThrough) { const contentChanged = getLast(addChangeThrough); await waitForOrFailTest(() => { - should.equal(contentChanged, true, 'Adding an content element must result in a content change.'); + should.deepEqual(contentChanged, { contentChange: true, troughEvent: false }, 'Adding an content element must result in a content change.'); }); } }; @@ -297,7 +292,11 @@ const addRemoveElementsTest = async (slot: Element | null, changeThrough?: DOMCo if (removeChangeThrough) { const contentChanged = getLast(removeChangeThrough); - should.equal(contentChanged, true, 'Removing an content element must result in a content change.'); + should.deepEqual( + contentChanged, + { contentChange: true, troughEvent: false }, + 'Removing an content element must result in a content change.' + ); } }); } @@ -361,10 +360,14 @@ const addRemoveImgElmsFn = async () => { compare(2); const previousContentChanged = getLast(domContentObserverObservations, 1); - should.equal(previousContentChanged, true, 'Adding an content image must result in a content change.'); + should.deepEqual( + previousContentChanged, + { contentChange: true, troughEvent: false }, + 'Adding an content image must result in a content change.' + ); const lastContentChanged = getLast(domContentObserverObservations); - should.equal(lastContentChanged, true, 'The images load event must result in a content change.'); + should.deepEqual(lastContentChanged, { contentChange: true, troughEvent: true }, 'The images load event must result in a content change.'); }); }; @@ -375,21 +378,20 @@ const addRemoveImgElmsFn = async () => { // test event content change debounce const addMultiple = async () => { const { before, after, compare } = changedThrough(domContentObserverObservations); - const addMultipleItem = () => { + const genImage = () => { const img = new Image(1, 1); img.src = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; const imgHolder = createDiv('img'); appendChildren(imgHolder, img); - appendChildren(imgElmsSlot, imgHolder); + return imgHolder; }; - before(); + await timeout(250); - addMultipleItem(); - addMultipleItem(); - addMultipleItem(); + before(); + appendChildren(imgElmsSlot, [genImage(), genImage(), genImage()]); await timeout(250); @@ -398,20 +400,27 @@ const addRemoveImgElmsFn = async () => { compare(2); const previousContentChanged = getLast(domContentObserverObservations, 1); - should.equal(previousContentChanged, true, 'Adding mutliple content images must result in a single content change. (debounced)'); + should.deepEqual( + previousContentChanged, + { contentChange: true, troughEvent: false }, + 'Adding mutliple content images must result in a single content change. (debounced)' + ); const lastContentChanged = getLast(domContentObserverObservations); - should.equal(lastContentChanged, true, 'Multiple images load events must result in a single cintent change. (debounced)'); + should.deepEqual( + lastContentChanged, + { contentChange: true, troughEvent: true }, + 'Multiple images load events must result in a single cintent change. (debounced)' + ); }); }; await addMultiple(); // remove load event from image test - const addChanged = async ( - newEventContentChange: Array<[string | null | undefined, (() => string | null | undefined) | string | null | undefined] | null | undefined> - ) => { - contentDomObserver._updateEventContentChange(newEventContentChange); + const addChanged = async (newEventContentChange: Array<[string | null | undefined, string | null | undefined] | null | undefined>) => { + contentDomObserver._destroy(); + contentDomObserver = createContentDomOserver(newEventContentChange); const img = new Image(1, 1); img.src = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; @@ -430,7 +439,8 @@ const addRemoveImgElmsFn = async () => { compare(1); }); - contentDomObserver._updateEventContentChange(contentChangeArr); + contentDomObserver._destroy(); + contentDomObserver = createContentDomOserver(contentChangeArr); }; await addChanged([ @@ -440,9 +450,6 @@ const addRemoveImgElmsFn = async () => { ['img', undefined], [null, null], [undefined, undefined], - ['img', () => 'hi'], - ['img', () => null], - ['img', () => undefined], null, undefined, ]); @@ -471,7 +478,11 @@ const addRemoveTransitionElmsFn = async () => { compareTransition(expectTransitionEndContentChange ? 2 : 1); // 2 because 1: added class mutation and 2: transition end event const contentChanged = getLast(domContentObserverObservations); - should.equal(contentChanged, true, 'The transitionend event must trigger a event content change.'); + should.deepEqual( + contentChanged, + { contentChange: true, troughEvent: expectTransitionEndContentChange }, + 'The transitionend event must trigger a event content change.' + ); resolve(1); }); }, @@ -495,11 +506,16 @@ const addRemoveTransitionElmsFn = async () => { compare(1); const contentChanged = getLast(domContentObserverObservations); - should.equal(contentChanged, true, 'Adding an content element (transition) must result in a content change.'); + should.deepEqual( + contentChanged, + { contentChange: true, troughEvent: false }, + 'Adding an content element (transition) must result in a content change.' + ); }); await startTransition(elm, expectTransitionEndContentChange && true); - contentDomObserver._updateEventContentChange(contentChangeArr); + contentDomObserver._destroy(); + contentDomObserver = createContentDomOserver(contentChangeArr); await startTransition(elm, expectTransitionEndContentChange && false); removeElements(elm); @@ -509,19 +525,8 @@ const addRemoveTransitionElmsFn = async () => { await add(false); - contentDomObserver._updateEventContentChange( - contentChangeArr.concat([ - [ - '.transition', - (elms) => { - elms.forEach((elm) => { - should.equal(hasClass(elm as Element, 'transition'), true, 'Every checked element must match the correpsonding selector.'); // in this case "".transition" - }); - return 'transitionend'; - }, - ], - ]) - ); + contentDomObserver._destroy(); + contentDomObserver = createContentDomOserver(contentChangeArr.concat([['.transition', 'transitionend']])); await add(true); }; @@ -562,7 +567,11 @@ const iterateTargetAttrChange = async () => { const iterateContentAttrChange = async () => { await iterateAttrChange(setContentAttr, domContentObserverObservations, (observation) => { const contentChanged = observation; - should.equal(contentChanged, true, 'A attribute change inside the content must trigger a content change for a DOMContentObserver.'); + should.deepEqual( + contentChanged, + { contentChange: true, troughEvent: false }, + 'A attribute change inside the content must trigger a content change for a DOMContentObserver.' + ); }); await iterateAttrChange(setFilteredContentAttr); }; @@ -573,7 +582,11 @@ const iterateContentBetweenAttrChange = async () => { const iterateContentHostElmAttrChange = async () => { await iterateAttrChange(setContentHostElmAttr, domContentObserverObservations, (observation) => { const contentChanged = observation; - should.equal(contentChanged, true, 'A attribute change for a nested target must trigger a content change for a DOMContentObserver.'); + should.deepEqual( + contentChanged, + { contentChange: true, troughEvent: false }, + 'A attribute change for a nested target must trigger a content change for a DOMContentObserver.' + ); }); await iterateAttrChange(setFilteredContentHostElmAttr); }; @@ -626,11 +639,9 @@ const start = async () => { targetDomObserver._destroy(); targetDomObserver._update(); - contentDomObserver._updateEventContentChange([]); contentDomObserver._update(); contentDomObserver._destroy(); contentDomObserver._destroy(); - contentDomObserver._updateEventContentChange([]); contentDomObserver._update(); }; diff --git a/packages/overlayscrollbars/tests/browser/observers/sizeObserver/index.browser.ts b/packages/overlayscrollbars/tests/browser/observers/sizeObserver/index.browser.ts index b9e0415..74b33c5 100644 --- a/packages/overlayscrollbars/tests/browser/observers/sizeObserver/index.browser.ts +++ b/packages/overlayscrollbars/tests/browser/observers/sizeObserver/index.browser.ts @@ -37,12 +37,14 @@ const preInitChildren = targetElm?.children.length; const sizeObserver = createSizeObserver( targetElm as HTMLElement, - (directionIsRTLCache?: any) => { - if (directionIsRTLCache) { - directionIterations += 1; - } else { + ({ _directionIsRTLCache, _sizeChanged, _appear }) => { + if (_sizeChanged) { sizeIterations += 1; } + + if (_directionIsRTLCache) { + directionIterations += 1; + } requestAnimationFrame(() => { if (resizesSlot) { resizesSlot.textContent = (directionIterations + sizeIterations).toString(); diff --git a/rollup.config.base.js b/rollup.config.base.js index d9e7af3..c8a8e44 100644 --- a/rollup.config.base.js +++ b/rollup.config.base.js @@ -268,7 +268,7 @@ const rollupConfig = (config = {}, { project = process.cwd(), overwrite = {}, si plugins: [ ...(output.plugins || []), rollupTerser({ - ecma: 8, + ecma: esm ? 2015 : 5, safari10: true, mangle: { safari10: true,