diff --git a/packages/overlayscrollbars/src/options.ts b/packages/overlayscrollbars/src/options.ts index ba721d28..5e16305d 100644 --- a/packages/overlayscrollbars/src/options.ts +++ b/packages/overlayscrollbars/src/options.ts @@ -195,9 +195,9 @@ export type Options = { autoHideDelay: number; /** Whether the scrollbars auto hide behavior is suspended until a scroll happened. */ autoHideSuspend: boolean; - /** Whether its possible to drag the handle of a scrollbar to scroll the viewport. */ + /** Whether it is possible to drag the handle of a scrollbar to scroll the viewport. */ dragScroll: boolean; - /** Whether its possible to click the track of a scrollbar to scroll the viewport. */ + /** Whether it is possible to click the track of a scrollbar to scroll the viewport. */ clickScroll: boolean; /** * An array of pointer types which shall be supported. diff --git a/packages/overlayscrollbars/src/plugins/clickScrollPlugin/clickScrollPlugin.ts b/packages/overlayscrollbars/src/plugins/clickScrollPlugin/clickScrollPlugin.ts index 8334e7c5..94fe053c 100644 --- a/packages/overlayscrollbars/src/plugins/clickScrollPlugin/clickScrollPlugin.ts +++ b/packages/overlayscrollbars/src/plugins/clickScrollPlugin/clickScrollPlugin.ts @@ -1,5 +1,4 @@ import { animateNumber, noop, selfClearTimeout } from '~/support'; -import type { EasingFn } from '~/support'; import type { StaticPlugin } from '~/plugins'; export const clickScrollPluginModuleName = '__osClickScrollPlugin'; @@ -10,65 +9,87 @@ export const ClickScrollPlugin = /* @__PURE__ */ (() => ({ () => ( moveHandleRelative: (deltaMovement: number) => void, - getHandleOffset: (handleRect?: DOMRect, trackRect?: DOMRect) => number, - startOffset: number, + targetOffset: number, handleLength: number, - relativeTrackPointerOffset: number + onClickScrollCompleted: (stopped: boolean) => void ) => { - // click scroll animation has 2 parts: + // click scroll animation has 2 main parts: // 1. the "click" which scrolls 100% of the viewport in a certain amount of time // 2. the "press" which scrolls to the point where the cursor is located, the "press" always waits for the "click" to finish // The "click" should not be canceled by a "pointerup" event because very fast clicks or taps would cancel it too fast // The "click" should only be canceled by a subsequent "pointerdown" event because otherwise 2 animations would run // The "press" should be canceld by the next "pointerup" event - let stop = false; - let stopClickAnimation = noop; + + let stopped = false; let stopPressAnimation = noop; - const [setFirstIterationPauseTimeout, clearFirstIterationPauseTimeout] = - selfClearTimeout(133); - const animateClickScroll = ( - clickScrollProgress: number, - iteration: number, - easing?: EasingFn | false - ) => + const linearScrollMs = 133; + const easedScrollMs = 222; + const [setPressAnimationTimeout, clearPressAnimationTimeout] = + selfClearTimeout(linearScrollMs); + const targetOffsetSign = Math.sign(targetOffset); + const handleLengthWithTargetSign = handleLength * targetOffsetSign; + const handleLengthWithTargetSignHalf = handleLengthWithTargetSign / 2; + const easing = (x: number) => 1 - (1 - x) * (1 - x); // easeOutQuad; + const easedEndPressAnimation = (from: number, to: number) => + animateNumber(from, to, easedScrollMs, moveHandleRelative, easing); + const linearPressAnimation = (linearFrom: number, msFactor: number) => animateNumber( - clickScrollProgress, - clickScrollProgress + handleLength * Math.sign(startOffset), - iteration ? 133 : 222, - (animationProgress, _, animationCompleted) => { - moveHandleRelative(animationProgress); - const handleStartBound = getHandleOffset(); - const handleEndBound = handleStartBound + handleLength; - const mouseBetweenHandleBounds = - relativeTrackPointerOffset >= handleStartBound && - relativeTrackPointerOffset <= handleEndBound; - const animationCompletedAction = () => { - stopPressAnimation = animateClickScroll(animationProgress, iteration + 1); - }; + linearFrom, + targetOffset - handleLengthWithTargetSign, + linearScrollMs * msFactor, + (progress, _, completed) => { + moveHandleRelative(progress); - if (!stop && animationCompleted && !mouseBetweenHandleBounds) { - if (iteration) { - animationCompletedAction(); - } else { - setFirstIterationPauseTimeout(animationCompletedAction); - } + if (completed) { + stopPressAnimation = easedEndPressAnimation(progress, targetOffset); } - }, - easing + } ); + const stopClickAnimation = animateNumber( + 0, + handleLengthWithTargetSign, + easedScrollMs, + (clickAnimationProgress, _, clickAnimationCompleted) => { + moveHandleRelative(clickAnimationProgress); + + if (clickAnimationCompleted) { + onClickScrollCompleted(stopped); - // easeOutQuad - stopClickAnimation = animateClickScroll(0, 0, (x) => 1 - (1 - x) * (1 - x)); + if (!stopped) { + const remainingScrollDistance = targetOffset - clickAnimationProgress; + const continueWithPress = + Math.sign(remainingScrollDistance - handleLengthWithTargetSignHalf) === + targetOffsetSign; + + continueWithPress && + setPressAnimationTimeout(() => { + const remainingLinearScrollDistance = + remainingScrollDistance - handleLengthWithTargetSign; + const linearBridge = + Math.sign(remainingLinearScrollDistance) === targetOffsetSign; + + stopPressAnimation = linearBridge + ? linearPressAnimation( + clickAnimationProgress, + Math.abs(remainingLinearScrollDistance) / handleLength + ) + : easedEndPressAnimation(clickAnimationProgress, targetOffset); + }); + } + } + }, + easing + ); return (stopClick?: boolean) => { - stop = true; - clearFirstIterationPauseTimeout(); + stopped = true; + if (stopClick) { stopClickAnimation(); - stopPressAnimation(); - } else { - stopPressAnimation(); } + + clearPressAnimationTimeout(); + stopPressAnimation(); }; }, }, diff --git a/packages/overlayscrollbars/src/setups/scrollbarsSetup/scrollbarsSetup.events.ts b/packages/overlayscrollbars/src/setups/scrollbarsSetup/scrollbarsSetup.events.ts index 4a58b4cf..23374498 100644 --- a/packages/overlayscrollbars/src/setups/scrollbarsSetup/scrollbarsSetup.events.ts +++ b/packages/overlayscrollbars/src/setups/scrollbarsSetup/scrollbarsSetup.events.ts @@ -135,23 +135,26 @@ export const createScrollbarsSetupEvents = ( runEachAndClear(pointerupCleanupFns); pointerCaptureElement.releasePointerCapture(pointerUpEvent.pointerId); }; + const nonAnimatedScroll = isDragScroll || instantClickScroll; const revertScrollObscuringStyles = _removeScrollObscuringStyles(); const pointerupCleanupFns = [ () => { - const withoutSnapScrollOffset = getElementScroll(_scrollOffsetElement); - revertScrollObscuringStyles(); - const withSnapScrollOffset = getElementScroll(_scrollOffsetElement); - const snapScrollDiff = { - x: withSnapScrollOffset.x - withoutSnapScrollOffset.x, - y: withSnapScrollOffset.y - withoutSnapScrollOffset.y, - }; + if (nonAnimatedScroll) { + const withoutSnapScrollOffset = getElementScroll(_scrollOffsetElement); + revertScrollObscuringStyles(); + const withSnapScrollOffset = getElementScroll(_scrollOffsetElement); + const snapScrollDiff = { + x: withSnapScrollOffset.x - withoutSnapScrollOffset.x, + y: withSnapScrollOffset.y - withoutSnapScrollOffset.y, + }; - if (mathAbs(snapScrollDiff.x) > 3 || mathAbs(snapScrollDiff.y) > 3) { - _removeScrollObscuringStyles(); - scrollElementTo(_scrollOffsetElement, withoutSnapScrollOffset); - scrollOffsetElementScrollBy(snapScrollDiff); - scrollSnapScrollTransitionTimeout(revertScrollObscuringStyles); + if (mathAbs(snapScrollDiff.x) > 3 || mathAbs(snapScrollDiff.y) > 3) { + _removeScrollObscuringStyles(); + scrollElementTo(_scrollOffsetElement, withoutSnapScrollOffset); + scrollOffsetElementScrollBy(snapScrollDiff); + scrollSnapScrollTransitionTimeout(revertScrollObscuringStyles); + } } }, addEventListener(_documentElm, releasePointerCaptureEvents, releasePointerCapture), @@ -162,7 +165,7 @@ export const createScrollbarsSetupEvents = ( addEventListener(_track, 'pointermove', (pointerMoveEvent: PointerEvent) => { const relativeMovement = pointerMoveEvent[clientXYKey] - pointerDownOffset; - if (isDragScroll || instantClickScroll) { + if (nonAnimatedScroll) { moveHandleRelative(startOffset + relativeMovement); } }), @@ -179,13 +182,19 @@ export const createScrollbarsSetupEvents = ( if (animateClickScroll) { const stopClickScrollAnimation = animateClickScroll( moveHandleRelative, - getHandleOffset, startOffset, handleLength, - relativeTrackPointerOffset + (stopped) => { + // if the scroll animation doesn't continue with a press + if (stopped) { + revertScrollObscuringStyles(); + } else { + push(pointerupCleanupFns, revertScrollObscuringStyles); + } + } ); - push(pointerupCleanupFns, bind(stopClickScrollAnimation)); + push(pointerupCleanupFns, stopClickScrollAnimation); push(pointerdownCleanupFns, bind(stopClickScrollAnimation, true)); } } diff --git a/packages/overlayscrollbars/src/support/utils/array.ts b/packages/overlayscrollbars/src/support/utils/array.ts index a7b4d59f..cbe171df 100644 --- a/packages/overlayscrollbars/src/support/utils/array.ts +++ b/packages/overlayscrollbars/src/support/utils/array.ts @@ -109,7 +109,7 @@ export const deduplicateArray = (array: T): T => from(new Set(a */ export const runEachAndClear = (arr: RunEachItem[], args?: any[], keep?: boolean): void => { // eslint-disable-next-line prefer-spread - const runFn = (fn: RunEachItem) => fn && fn.apply(undefined, args || []); + const runFn = (fn: RunEachItem) => (fn ? fn.apply(undefined, args || []) : true); // return true when fn is falsy to not break the loop each(arr, runFn); !keep && ((arr as any[]).length = 0); }; diff --git a/packages/overlayscrollbars/test/jest-jsdom/support/utils/arrays.test.ts b/packages/overlayscrollbars/test/jest-jsdom/support/utils/arrays.test.ts index 11f1d12b..39317153 100644 --- a/packages/overlayscrollbars/test/jest-jsdom/support/utils/arrays.test.ts +++ b/packages/overlayscrollbars/test/jest-jsdom/support/utils/arrays.test.ts @@ -280,15 +280,31 @@ describe('array utilities', () => { describe('runEachAndClear', () => { test('array', () => { - const arr = [jest.fn(), null, jest.fn(), undefined, jest.fn()]; + const firstFn = jest.fn(); + const middleFn = jest.fn(); + const lastFn = jest.fn(); + const arr = [ + firstFn, + false as const, + null, + undefined, + middleFn, + undefined, + null, + false as const, + lastFn, + ]; runEachAndClear(arr, ['a', 'b', 'c', 'd'], true); - arr.forEach((fn) => { - if (fn) { - expect(fn).toHaveBeenCalledWith('a', 'b', 'c', 'd'); - } - }); runEachAndClear(arr, ['a', 'b', 'c', 'd']); + expect(arr.length).toBe(0); + + expect(firstFn).toHaveBeenCalledTimes(2); + expect(firstFn).toHaveBeenCalledWith('a', 'b', 'c', 'd'); + expect(middleFn).toHaveBeenCalledTimes(2); + expect(middleFn).toHaveBeenCalledWith('a', 'b', 'c', 'd'); + expect(lastFn).toHaveBeenCalledTimes(2); + expect(lastFn).toHaveBeenCalledWith('a', 'b', 'c', 'd'); }); });