add clickScrollPlguin for click scrolling support

This commit is contained in:
Rene Haas
2022-09-14 09:36:11 +02:00
parent 065d031f81
commit a7ca731a32
12 changed files with 357 additions and 77 deletions
@@ -0,0 +1,63 @@
import { animateNumber, noop } from 'support';
import type { Plugin } from 'plugins';
export type ClickScrollPluginInstance = {
_: (
moveHandleRelative: (deltaMovement: number) => void,
getHandleOffset: (handleRect?: DOMRect, trackRect?: DOMRect) => number,
startOffset: number,
handleLength: number,
relativeTrackPointerOffset: number
) => () => void;
};
export const clickScrollPluginName = '__osClickScrollPlugin';
export const clickScrollPlugin: Plugin<ClickScrollPluginInstance> = /* @__PURE__ */ (() => ({
[clickScrollPluginName]: {
_: (
moveHandleRelative,
getHandleOffset,
startOffset,
handleLength,
relativeTrackPointerOffset
) => {
// click scroll animation
let iteration = 0;
let clear = noop;
const animateClickScroll = (clickScrollProgress: number) => {
clear = animateNumber(
clickScrollProgress,
clickScrollProgress + handleLength * Math.sign(startOffset),
133,
(animationProgress, _, animationCompleted) => {
moveHandleRelative(animationProgress);
const handleStartBound = getHandleOffset();
const handleEndBound = handleStartBound + handleLength;
const mouseBetweenHandleBounds =
relativeTrackPointerOffset >= handleStartBound &&
relativeTrackPointerOffset <= handleEndBound;
if (animationCompleted && !mouseBetweenHandleBounds) {
if (iteration) {
animateClickScroll(animationProgress);
} else {
const firstIterationPauseTimeout = setTimeout(() => {
animateClickScroll(animationProgress);
}, 222);
clear = () => {
clearTimeout(firstIterationPauseTimeout);
};
}
iteration++;
}
}
);
};
animateClickScroll(0);
return () => clear();
},
},
}))();
@@ -0,0 +1 @@
export * from 'plugins/clickScrollPlugin/clickScrollPlugin';
@@ -2,3 +2,4 @@ export * from './plugins';
export * from './optionsValidationPlugin';
export * from './sizeObserverPlugin';
export * from './scrollbarsHidingPlugin';
export * from './clickScrollPlugin';
@@ -10,17 +10,16 @@ import {
selfClearTimeout,
parent,
closest,
rAF,
cAF,
push,
noop,
} from 'support';
import { getPlugins, clickScrollPluginName } from 'plugins';
import { getEnvironment } from 'environment';
import {
classNameScrollbarHandle,
classNamesScrollbarInteraction,
classNamesScrollbarWheel,
} from 'classnames';
import type { ClickScrollPluginInstance } from 'plugins';
import type { ReadonlyOptions } from 'options';
import type { StructureSetupState } from 'setups';
import type {
@@ -37,31 +36,7 @@ export type ScrollbarsSetupEvents = (
isHorizontal?: boolean
) => () => void;
const { round, max, sign } = Math;
const animationCurrentTime = () => performance.now();
const animateNumber = (
from: number,
to: number,
duration: number,
onFrame: (progress: number, completed: boolean) => any
) => {
let animationFrameId = 0;
const timeStart = animationCurrentTime();
const frame = () => {
const timeNow = animationCurrentTime();
const timeElapsed = timeNow - timeStart;
const stopAnimation = timeElapsed >= duration;
const percent = 1 - (max(0, timeStart + duration - timeNow) / duration || 0);
const progress = (to - from) * percent + from;
const animationCompleted = stopAnimation || percent === 1;
onFrame(progress, animationCompleted);
animationFrameId = animationCompleted ? 0 : rAF!(frame);
};
frame();
return () => cAF!(animationFrameId);
};
const { round } = Math;
const getScale = (element: HTMLElement): XY<number> => {
const { width, height } = getBoundingClientRect(element);
const { w, h } = offsetSize(element);
@@ -108,8 +83,7 @@ const createInteractiveScrollEvents = (
const leftTopKey = isHorizontal ? 'left' : 'top'; // for BCR (can't use xy because of IE11)
const whKey = isHorizontal ? 'w' : 'h';
const xyKey = isHorizontal ? 'x' : 'y';
const getHandleOffset = (handleRect: DOMRect, trackRect: DOMRect) =>
handleRect[leftTopKey] - trackRect[leftTopKey];
const createRelativeHandleMove =
(mouseDownScroll: number, invertedScale: number) => (deltaMovement: number) => {
const { _overflowAmount } = structureSetupState();
@@ -129,13 +103,17 @@ const createInteractiveScrollEvents = (
if (continuePointerDown(pointerDownEvent, options, isDragScroll)) {
const instantClickScroll = !isDragScroll && pointerDownEvent.shiftKey;
const getHandleRect = () => getBoundingClientRect(_handle);
const getTrackRect = () => getBoundingClientRect(_track);
const getHandleOffset = (handleRect?: DOMRect, trackRect?: DOMRect) =>
(handleRect || getHandleRect())[leftTopKey] - (trackRect || getTrackRect())[leftTopKey];
const moveHandleRelative = createRelativeHandleMove(
scrollOffsetElement[scrollLeftTopKey] || 0,
1 / getScale(scrollOffsetElement)[xyKey]
);
const pointerDownOffset = pointerDownEvent[clientXYKey];
const handleRect = getBoundingClientRect(_handle);
const trackRect = getBoundingClientRect(_track);
const handleRect = getHandleRect();
const trackRect = getTrackRect();
const handleLength = handleRect[widthHeightKey];
const handleCenter = getHandleOffset(handleRect, trackRect) + handleLength / 2;
const relativeTrackPointerOffset = pointerDownOffset - trackRect[leftTopKey];
@@ -157,42 +135,22 @@ const createInteractiveScrollEvents = (
if (instantClickScroll) {
moveHandleRelative(startOffset);
} else if (!isDragScroll) {
// click scroll animation
let iteration = 0;
let clear = noop;
const animateClickScroll = (clickScrollProgress: number) => {
clear = animateNumber(
clickScrollProgress,
clickScrollProgress + handleLength * sign(startOffset),
133,
(animationProgress, animationCompleted) => {
moveHandleRelative(animationProgress);
const handleStartBound = getHandleOffset(getBoundingClientRect(_handle), trackRect);
const handleEndBound = handleStartBound + handleLength;
const mouseBetweenHandleBounds =
relativeTrackPointerOffset >= handleStartBound &&
relativeTrackPointerOffset <= handleEndBound;
const sizeObserverPlugin = getPlugins()[clickScrollPluginName] as
| ClickScrollPluginInstance
| undefined;
if (animationCompleted && !mouseBetweenHandleBounds) {
if (iteration) {
animateClickScroll(animationProgress);
} else {
const firstIterationPauseTimeout = setTimeout(() => {
animateClickScroll(animationProgress);
}, 222);
clear = () => {
clearTimeout(firstIterationPauseTimeout);
};
}
iteration++;
}
}
if (sizeObserverPlugin) {
push(
offFns,
sizeObserverPlugin._(
moveHandleRelative,
getHandleOffset,
startOffset,
handleLength,
relativeTrackPointerOffset
)
);
};
animateClickScroll(0);
push(offFns, () => clear());
}
}
on(
@@ -0,0 +1,56 @@
import { rAF, cAF } from 'support/compatibility';
import { isFunction } from 'support/utils';
const { max } = Math;
const animationCurrentTime = () => performance.now();
/**
* percent: current percent (0 - 1),
* time: current time (duration * percent),
* min: start value
* max: end value
* duration: duration in ms
*/
export type EasingFn = (
percent: number,
time: number,
min: number,
max: number,
duration: number
) => number;
export const animateNumber = (
from: number,
to: number,
duration: number,
onFrame: (progress: number, percent: number, completed: boolean) => any,
easing?: EasingFn | false
): ((complete?: boolean) => void) => {
let animationFrameId = 0;
const timeStart = animationCurrentTime();
const finalDuration = Math.max(0, duration);
const frame = (complete?: boolean) => {
const timeNow = animationCurrentTime();
const timeElapsed = timeNow - timeStart;
const stopAnimation = timeElapsed >= finalDuration;
const percent = complete
? 1
: 1 - (max(0, timeStart + finalDuration - timeNow) / finalDuration || 0);
const progress =
(to - from) *
(isFunction(easing)
? easing(percent, percent * finalDuration, 0, 1, finalDuration)
: percent) +
from;
const animationCompleted = stopAnimation || percent === 1;
onFrame && onFrame(progress, percent, animationCompleted);
animationFrameId = animationCompleted ? 0 : rAF!(() => frame());
};
frame();
return (complete) => {
cAF!(animationFrameId);
complete && frame(complete);
};
};
@@ -1,3 +1,4 @@
export * from 'support/dom/animation';
export * from 'support/dom/attribute';
export * from 'support/dom/class';
export * from 'support/dom/create';
@@ -9,9 +9,11 @@ jest.mock('support/compatibility/apis', () => {
...originalModule,
// @ts-ignore
rAF: jest.fn().mockImplementation((...args) => mockRAF(...args)),
// @ts-ignore
cAF: jest.fn().mockImplementation((...args) => clearTimeout(...args)),
// @ts-ignore
setT: jest.fn().mockImplementation((...args) => setTimeout(...args)),
// @ts-ignore
clearT: jest.fn().mockImplementation((...args) => clearTimeout(...args)),
};
});
@@ -26,6 +26,7 @@ jest.mock('support/compatibility/apis', () => {
...originalModule,
// @ts-ignore
setT: jest.fn().mockImplementation((...args) => setTimeout(...args)),
// @ts-ignore
clearT: jest.fn().mockImplementation((...args) => clearTimeout(...args)),
};
});
@@ -0,0 +1,187 @@
import { animateNumber } from 'support/dom/animation';
jest.useFakeTimers();
jest.mock('support/compatibility/apis', () => {
const originalModule = jest.requireActual('support/compatibility/apis');
const mockRAF = (arg: any) => setTimeout(arg, 0);
return {
...originalModule,
// @ts-ignore
rAF: jest.fn().mockImplementation((...args) => mockRAF(...args)),
// @ts-ignore
cAF: jest.fn().mockImplementation((...args) => clearTimeout(...args)),
// @ts-ignore
setT: jest.fn().mockImplementation((...args) => setTimeout(...args)),
// @ts-ignore
clearT: jest.fn().mockImplementation((...args) => clearTimeout(...args)),
};
});
describe('dom animation', () => {
describe('animateNumber', () => {
test('animate 0 to 1', () => {
const onFrame = jest.fn();
expect(onFrame).not.toHaveBeenCalled();
animateNumber(0, 1, 500, onFrame);
expect(onFrame).toHaveBeenCalledTimes(1);
expect(onFrame).toHaveBeenLastCalledWith(0, 0, false);
jest.advanceTimersByTime(250);
expect(onFrame).toHaveBeenCalledTimes(252);
expect(onFrame).toHaveBeenLastCalledWith(0.5, 0.5, false);
jest.advanceTimersByTime(250);
expect(onFrame).toHaveBeenCalledTimes(502);
expect(onFrame).toHaveBeenLastCalledWith(1, 1, true);
jest.runAllTimers();
expect(onFrame).toHaveBeenCalledTimes(502);
});
test('animate 1 to 0', () => {
const onFrame = jest.fn();
expect(onFrame).not.toHaveBeenCalled();
animateNumber(1, 0, 1000, onFrame);
expect(onFrame).toHaveBeenCalledTimes(1);
expect(onFrame).toHaveBeenLastCalledWith(1, 0, false);
jest.advanceTimersByTime(500);
expect(onFrame).toHaveBeenCalledTimes(502);
expect(onFrame).toHaveBeenLastCalledWith(0.5, 0.5, false);
jest.advanceTimersByTime(500);
expect(onFrame).toHaveBeenCalledTimes(1002);
expect(onFrame).toHaveBeenLastCalledWith(0, 1, true);
jest.runAllTimers();
expect(onFrame).toHaveBeenCalledTimes(1002);
});
test('animate duration 0', () => {
const onFrame = jest.fn();
expect(onFrame).not.toHaveBeenCalled();
animateNumber(0, 100, 0, onFrame);
expect(onFrame).toHaveBeenCalledTimes(1);
expect(onFrame).toHaveBeenLastCalledWith(100, 1, true);
jest.runAllTimers();
expect(onFrame).toHaveBeenCalledTimes(1);
});
test('animate negative duration', () => {
const onFrame = jest.fn();
expect(onFrame).not.toHaveBeenCalled();
animateNumber(0, 100, -100, onFrame);
expect(onFrame).toHaveBeenCalledTimes(1);
expect(onFrame).toHaveBeenLastCalledWith(100, 1, true);
jest.runAllTimers();
expect(onFrame).toHaveBeenCalledTimes(1);
});
test('animate with easing and fractions', () => {
const onFrame = jest.fn();
const onFrameB = jest.fn();
expect(onFrame).not.toHaveBeenCalled();
expect(onFrameB).not.toHaveBeenCalled();
animateNumber(25.2, 55.5, 1000, onFrame, (percent) => percent);
animateNumber(25.2, 55.5, 1000, onFrameB);
expect(onFrame).toHaveBeenCalledTimes(1);
expect(onFrameB).toHaveBeenCalledTimes(1);
expect(onFrame).toHaveBeenLastCalledWith(25.2, 0, false);
expect(onFrameB).toHaveBeenLastCalledWith(25.2, 0, false);
jest.advanceTimersByTime(250);
expect(onFrame).toHaveBeenCalledTimes(252);
expect(onFrameB).toHaveBeenCalledTimes(252);
expect(onFrame).toHaveBeenLastCalledWith(32.775, 0.25, false);
expect(onFrameB).toHaveBeenLastCalledWith(32.775, 0.25, false);
jest.advanceTimersByTime(250);
expect(onFrame).toHaveBeenCalledTimes(502);
expect(onFrameB).toHaveBeenCalledTimes(502);
expect(onFrame).toHaveBeenLastCalledWith(40.35, 0.5, false);
expect(onFrameB).toHaveBeenLastCalledWith(40.35, 0.5, false);
jest.advanceTimersByTime(100);
expect(onFrame).toHaveBeenCalledTimes(602);
expect(onFrameB).toHaveBeenCalledTimes(602);
expect(onFrame).toHaveBeenLastCalledWith(43.379999999999995, 0.6, false);
expect(onFrameB).toHaveBeenLastCalledWith(43.379999999999995, 0.6, false);
jest.runAllTimers();
expect(onFrame).toHaveBeenCalledTimes(1002);
expect(onFrameB).toHaveBeenCalledTimes(1002);
expect(onFrame).toHaveBeenLastCalledWith(55.5, 1, true);
expect(onFrameB).toHaveBeenLastCalledWith(55.5, 1, true);
});
test('animate and stop animation', () => {
const onFrame = jest.fn();
expect(onFrame).not.toHaveBeenCalled();
const stop = animateNumber(1, 0, 1000, onFrame);
expect(onFrame).toHaveBeenCalledTimes(1);
expect(onFrame).toHaveBeenLastCalledWith(1, 0, false);
stop();
expect(onFrame).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(500);
expect(onFrame).toHaveBeenCalledTimes(1);
jest.runAllTimers();
expect(onFrame).toHaveBeenCalledTimes(1);
});
test('animate and stop animation with complete', () => {
const onFrame = jest.fn();
expect(onFrame).not.toHaveBeenCalled();
const stop = animateNumber(0, 5555, 1000, onFrame);
expect(onFrame).toHaveBeenCalledTimes(1);
expect(onFrame).toHaveBeenLastCalledWith(0, 0, false);
stop(true);
expect(onFrame).toHaveBeenCalledTimes(2);
expect(onFrame).toHaveBeenLastCalledWith(5555, 1, true);
jest.advanceTimersByTime(500);
expect(onFrame).toHaveBeenCalledTimes(2);
jest.runAllTimers();
expect(onFrame).toHaveBeenCalledTimes(2);
});
});
});
@@ -10,9 +10,11 @@ jest.mock('support/compatibility/apis', () => {
...originalModule,
// @ts-ignore
rAF: jest.fn().mockImplementation((...args) => mockRAF(...args)),
// @ts-ignore
cAF: jest.fn().mockImplementation((...args) => clearTimeout(...args)),
// @ts-ignore
setT: jest.fn().mockImplementation((...args) => setTimeout(...args)),
// @ts-ignore
clearT: jest.fn().mockImplementation((...args) => clearTimeout(...args)),
};
});
@@ -17,8 +17,8 @@ const normalizePath = (pathName) =>
const fixturesDir = path.join(__dirname, '.fixtures');
const libraryFixturePath = normalizePath(path.join(fixturesDir, 'lib.js'));
const unshakedFixturePath = normalizePath(path.join(fixturesDir, 'unshaked.js'));
const shakedFixturePath = normalizePath(path.join(fixturesDir, 'shaked.js'));
const normalFixturePath = normalizePath(path.join(fixturesDir, 'nromal.js'));
const treeshakedFixturePath = normalizePath(path.join(fixturesDir, 'treeshaked.js'));
const unshakedFixtureContent = `
export * as os from '${libraryFixturePath}';
@@ -89,18 +89,24 @@ const bundleFunctions = {
const testBundler = (bundlerName) => async () => {
const bundleFunction = bundleFunctions[bundlerName];
const outputDir = path.join(__dirname, `.${bundlerName}`);
const unshaked = await bundleFunction(unshakedFixturePath, path.join(outputDir, 'unshaked.js'));
const shaked = await bundleFunction(shakedFixturePath, path.join(outputDir, 'shaked.js'));
const normal = await bundleFunction(
normalFixturePath,
path.join(outputDir, path.basename(normalFixturePath))
);
const treeshaked = await bundleFunction(
treeshakedFixturePath,
path.join(outputDir, path.basename(treeshakedFixturePath))
);
cleanBundle && fs.rmSync(outputDir, { recursive: true });
console.info(`${bundlerName} size`, {
unshaked,
shaked,
diff: unshaked - shaked,
normal,
treeshaked,
diff: normal - treeshaked,
});
expect(unshaked - shaked).toBeGreaterThan(expectedBundleDiff);
expect(normal - treeshaked).toBeGreaterThan(expectedBundleDiff);
};
describe('tree shaking', () => {
@@ -131,8 +137,8 @@ describe('tree shaking', () => {
fs.mkdirSync(fixturesDir);
}
fs.writeFileSync(unshakedFixturePath, unshakedFixtureContent);
fs.writeFileSync(shakedFixturePath, shakedFixtureContent);
fs.writeFileSync(normalFixturePath, unshakedFixtureContent);
fs.writeFileSync(treeshakedFixturePath, shakedFixtureContent);
}, 60000 * 2);
// clean the fixture
@@ -32,7 +32,9 @@ import {
} from 'support';
import { Options } from 'options';
import { DeepPartial } from 'typings';
import { addPlugin, scrollbarsHidingPlugin, sizeObserverPlugin } from 'plugins';
import { addPlugin, scrollbarsHidingPlugin, sizeObserverPlugin, clickScrollPlugin } from 'plugins';
addPlugin(clickScrollPlugin);
if (!window.ResizeObserver) {
addPlugin(sizeObserverPlugin);