add click scrolling implementation

This commit is contained in:
Rene Haas
2022-08-11 00:36:50 +02:00
parent 893451a10d
commit 865b74aab3
3 changed files with 132 additions and 40 deletions
@@ -9,9 +9,18 @@ import {
XY, XY,
selfCancelTimeout, selfCancelTimeout,
parent, parent,
closest,
rAF,
cAF,
push,
noop,
} from 'support'; } from 'support';
import { getEnvironment } from 'environment'; import { getEnvironment } from 'environment';
import { classNamesScrollbarInteraction, classNamesScrollbarWheel } from 'classnames'; import {
classNameScrollbarHandle,
classNamesScrollbarInteraction,
classNamesScrollbarWheel,
} from 'classnames';
import type { ReadonlyOptions } from 'options'; import type { ReadonlyOptions } from 'options';
import type { StructureSetupState } from 'setups'; import type { StructureSetupState } from 'setups';
import type { import type {
@@ -28,11 +37,31 @@ export type ScrollbarsSetupEvents = (
isHorizontal?: boolean isHorizontal?: boolean
) => () => void; ) => () => void;
const { round } = Math; const { round, max, sign } = Math;
const getClientOffset = (event: PointerEvent): XY<number> => ({ const animationCurrentTime = () => performance.now();
x: event.clientX, const animateNumber = (
y: event.clientY, 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 getScale = (element: HTMLElement): XY<number> => { const getScale = (element: HTMLElement): XY<number> => {
const { width, height } = getBoundingClientRect(element); const { width, height } = getBoundingClientRect(element);
const { w, h } = offsetSize(element); const { w, h } = offsetSize(element);
@@ -44,7 +73,7 @@ const getScale = (element: HTMLElement): XY<number> => {
const continuePointerDown = ( const continuePointerDown = (
event: PointerEvent, event: PointerEvent,
options: ReadonlyOptions, options: ReadonlyOptions,
scrollType: 'dragScroll' | 'clickScroll' isDragScroll: boolean
) => { ) => {
const scrollbarOptions = options.scrollbars; const scrollbarOptions = options.scrollbars;
const { button, isPrimary, pointerType } = event; const { button, isPrimary, pointerType } = event;
@@ -52,7 +81,7 @@ const continuePointerDown = (
return ( return (
button === 0 && button === 0 &&
isPrimary && isPrimary &&
scrollbarOptions[scrollType] && scrollbarOptions[isDragScroll ? 'dragScroll' : 'clickScroll'] &&
(pointers || []).includes(pointerType) (pointers || []).includes(pointerType)
); );
}; };
@@ -63,7 +92,7 @@ const createRootClickStopPropagationEvents = (scrollbar: HTMLElement, documentEl
on.bind(0, documentElm, 'click', stopPropagation, { _once: true, _capture: true }), on.bind(0, documentElm, 'click', stopPropagation, { _once: true, _capture: true }),
{ _capture: true } { _capture: true }
); );
const createDragScrollingEvents = ( const createInteractiveScrollEvents = (
options: ReadonlyOptions, options: ReadonlyOptions,
doc: Document, doc: Document,
scrollbarStructure: ScrollbarStructure, scrollbarStructure: ScrollbarStructure,
@@ -73,51 +102,107 @@ const createDragScrollingEvents = (
) => { ) => {
const { _rtlScrollBehavior } = getEnvironment(); const { _rtlScrollBehavior } = getEnvironment();
const { _handle, _track, _scrollbar } = scrollbarStructure; const { _handle, _track, _scrollbar } = scrollbarStructure;
const scrollOffsetKey = `scroll${isHorizontal ? 'Left' : 'Top'}`; const scrollLeftTopKey = `scroll${isHorizontal ? 'Left' : 'Top'}`;
const xyKey = `${isHorizontal ? 'x' : 'y'}`; const widthHeightKey = isHorizontal ? 'width' : 'height';
const whKey = `${isHorizontal ? 'w' : 'h'}`; const whKey = isHorizontal ? 'w' : 'h';
const createOnPointerMoveHandler = const xyKey = isHorizontal ? 'x' : 'y';
(mouseDownScroll: number, mouseDownClientOffset: number, mouseDownInvertedScale: number) => const getHandleOffset = (handleRect: DOMRect, trackRect: DOMRect) =>
(event: PointerEvent) => { handleRect[xyKey] - trackRect[xyKey];
const createRelativeHandleMove =
(mouseDownScroll: number, invertedScale: number) => (deltaMovement: number) => {
const { _overflowAmount } = structureSetupState(); const { _overflowAmount } = structureSetupState();
const movement =
(getClientOffset(event)[xyKey] - mouseDownClientOffset) * mouseDownInvertedScale;
const handleTrackDiff = offsetSize(_track)[whKey] - offsetSize(_handle)[whKey]; const handleTrackDiff = offsetSize(_track)[whKey] - offsetSize(_handle)[whKey];
const scrollDeltaPercent = movement / handleTrackDiff; const scrollDeltaPercent = (invertedScale * deltaMovement) / handleTrackDiff;
const scrollDelta = scrollDeltaPercent * _overflowAmount[xyKey]; const scrollDelta = scrollDeltaPercent * _overflowAmount[xyKey];
const isRTL = directionIsRTL(_scrollbar); const isRTL = directionIsRTL(_scrollbar);
const negateMultiplactor = const negateMultiplactor =
isRTL && isHorizontal ? (_rtlScrollBehavior.n || _rtlScrollBehavior.i ? 1 : -1) : 1; isRTL && isHorizontal ? (_rtlScrollBehavior.n || _rtlScrollBehavior.i ? 1 : -1) : 1;
scrollOffsetElement[scrollOffsetKey] = mouseDownScroll + scrollDelta * negateMultiplactor; scrollOffsetElement[scrollLeftTopKey] = mouseDownScroll + scrollDelta * negateMultiplactor;
}; };
return on(_handle, 'pointerdown', (pointerDownEvent: PointerEvent) => { return on(_track, 'pointerdown', (pointerDownEvent: PointerEvent) => {
if (continuePointerDown(pointerDownEvent, options, 'dragScroll')) { const isDragScroll =
const offSelectStart = on(doc, 'selectstart', (event: Event) => preventDefault(event), { closest(pointerDownEvent.target as Node, `.${classNameScrollbarHandle}`) === _handle;
_passive: false,
}); if (continuePointerDown(pointerDownEvent, options, isDragScroll)) {
const offPointerMove = on( const instantClickScroll = !isDragScroll && pointerDownEvent.shiftKey;
_handle, const moveHandleRelative = createRelativeHandleMove(
'pointermove', scrollOffsetElement[scrollLeftTopKey] || 0,
createOnPointerMoveHandler( 1 / getScale(scrollOffsetElement)[xyKey]
scrollOffsetElement[scrollOffsetKey] || 0,
getClientOffset(pointerDownEvent)[xyKey],
1 / getScale(scrollOffsetElement)[xyKey]
)
); );
const pointerDownOffset = pointerDownEvent[xyKey];
const handleRect = getBoundingClientRect(_handle);
const trackRect = getBoundingClientRect(_track);
const handleLength = handleRect[widthHeightKey];
const handleCenter = getHandleOffset(handleRect, trackRect) + handleLength / 2;
const relativeTrackPointerOffset = pointerDownOffset - trackRect[xyKey];
const startOffset = isDragScroll ? 0 : relativeTrackPointerOffset - handleCenter;
const offFns = [
on(doc, 'selectstart', (event: Event) => preventDefault(event), {
_passive: false,
}),
on(_track, 'pointermove', (pointerMoveEvent: PointerEvent) => {
const relativeMovement = pointerMoveEvent[xyKey] - pointerDownOffset;
if (isDragScroll || instantClickScroll) {
moveHandleRelative(startOffset + relativeMovement);
}
}),
];
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;
if (animationCompleted && !mouseBetweenHandleBounds) {
if (iteration) {
animateClickScroll(animationProgress);
} else {
const firstIterationPauseTimeout = setTimeout(() => {
animateClickScroll(animationProgress);
}, 222);
clear = () => {
clearTimeout(firstIterationPauseTimeout);
};
}
iteration++;
}
}
);
};
animateClickScroll(0);
push(offFns, () => clear());
}
on( on(
_handle, _track,
'pointerup', 'pointerup',
(pointerUpEvent: PointerEvent) => { (pointerUpEvent: PointerEvent) => {
offSelectStart(); runEachAndClear(offFns);
offPointerMove(); _track.releasePointerCapture(pointerUpEvent.pointerId);
_handle.releasePointerCapture(pointerUpEvent.pointerId);
}, },
{ _once: true } { _once: true }
); );
_handle.setPointerCapture(pointerDownEvent.pointerId); _track.setPointerCapture(pointerDownEvent.pointerId);
} }
}); });
}; };
@@ -174,7 +259,7 @@ export const createScrollbarsSetupEvents =
{ _passive: false, _capture: true } { _passive: false, _capture: true }
), ),
createRootClickStopPropagationEvents(_scrollbar, documentElm), createRootClickStopPropagationEvents(_scrollbar, documentElm),
createDragScrollingEvents( createInteractiveScrollEvents(
options, options,
documentElm, documentElm,
scrollbarStructure, scrollbarStructure,
@@ -66,11 +66,13 @@ body > .os-scrollbar {
.os-scrollbar-unusable .os-scrollbar-handle { .os-scrollbar-unusable .os-scrollbar-handle {
opacity: 0 !important; opacity: 0 !important;
} }
.os-scrollbar.os-scrollbar-horizontal.os-scrollbar-cornerless { .os-scrollbar.os-scrollbar-horizontal.os-scrollbar-cornerless,
.os-scrollbar.os-scrollbar-horizontal.os-scrollbar-cornerless.os-scrollbar-rtl {
left: 0; left: 0;
right: 0; right: 0;
} }
.os-scrollbar.os-scrollbar-vertical.os-scrollbar-cornerless { .os-scrollbar.os-scrollbar-vertical.os-scrollbar-cornerless,
.os-scrollbar.os-scrollbar-vertical.os-scrollbar-cornerless.os-scrollbar-rtl {
top: 0; top: 0;
bottom: 0; bottom: 0;
} }
@@ -43,6 +43,11 @@ window.OverlayScrollbars = OverlayScrollbars;
OverlayScrollbars.env().setDefaultInitialization({ OverlayScrollbars.env().setDefaultInitialization({
cancel: { nativeScrollbarsOverlaid: false }, cancel: { nativeScrollbarsOverlaid: false },
}); });
OverlayScrollbars.env().setDefaultOptions({
scrollbars: {
clickScroll: true,
},
});
interface Metrics { interface Metrics {
offset: { offset: {