From a1009d8679c03773761c7a75e2a2a95d20fd69e9 Mon Sep 17 00:00:00 2001 From: Timofey Boyko Date: Mon, 19 Feb 2024 15:34:15 +0300 Subject: [PATCH 1/3] Components:Scrollbar: move custom scrollbar from library to component and optimize for react 18 --- .../scrollbar/custom-scrollbar/Emittr.ts | 220 +++ .../scrollbar/custom-scrollbar/Loop.ts | 102 ++ .../scrollbar/custom-scrollbar/Scrollbar.tsx | 1522 +++++++++++++++++ .../custom-scrollbar/ScrollbarThumb.tsx | 234 +++ .../custom-scrollbar/ScrollbarTrack.tsx | 109 ++ .../scrollbar/custom-scrollbar/index.ts | 7 + .../scrollbar/custom-scrollbar/style.ts | 60 + .../scrollbar/custom-scrollbar/types.ts | 106 ++ .../scrollbar/custom-scrollbar/util.tsx | 298 ++++ .../components/scrollbar/styled-scrollbar.js | 2 +- 10 files changed, 2659 insertions(+), 1 deletion(-) create mode 100644 packages/components/scrollbar/custom-scrollbar/Emittr.ts create mode 100644 packages/components/scrollbar/custom-scrollbar/Loop.ts create mode 100644 packages/components/scrollbar/custom-scrollbar/Scrollbar.tsx create mode 100644 packages/components/scrollbar/custom-scrollbar/ScrollbarThumb.tsx create mode 100644 packages/components/scrollbar/custom-scrollbar/ScrollbarTrack.tsx create mode 100644 packages/components/scrollbar/custom-scrollbar/index.ts create mode 100644 packages/components/scrollbar/custom-scrollbar/style.ts create mode 100644 packages/components/scrollbar/custom-scrollbar/types.ts create mode 100644 packages/components/scrollbar/custom-scrollbar/util.tsx diff --git a/packages/components/scrollbar/custom-scrollbar/Emittr.ts b/packages/components/scrollbar/custom-scrollbar/Emittr.ts new file mode 100644 index 0000000000..4c411db460 --- /dev/null +++ b/packages/components/scrollbar/custom-scrollbar/Emittr.ts @@ -0,0 +1,220 @@ +import { isFun, isNum, isUndef } from "./util"; + +type EventHandler = (...args: unknown[]) => void; +type OnceHandler = OnceHandlerState & { (...args: unknown[]): void }; +type EventHandlersList = (OnceHandler | EventHandler)[]; +type EmitterEventHandlers = { [key: string]: EventHandlersList }; +type OnceHandlerState = { + fired: boolean; + handler: EventHandler; + wrappedHandler?: OnceHandler; + emitter: Emittr; + event: string; +}; + +export default class Emittr { + private _handlers: EmitterEventHandlers; + + private _maxHandlers: number; + + constructor(maxHandlers = 10) { + this.setMaxHandlers(maxHandlers); + this._handlers = Object.create(null); + } + + private static _callEventHandlers( + emitter: Emittr, + handlers: EventHandlersList, + args: unknown[], + ) { + if (!handlers.length) { + return; + } + if (handlers.length === 1) { + Reflect.apply(handlers[0], emitter, args); + return; + } + handlers = [...handlers]; + let idx; + for (idx = 0; idx < handlers.length; idx++) { + Reflect.apply(handlers[idx], emitter, args); + } + } + + private static _addHandler = ( + emitter: Emittr, + name: string, + handler: EventHandler, + prepend = false, + ): Emittr => { + if (!isFun(handler)) { + throw new TypeError( + `Expected event handler to be a function, got ${typeof handler}`, + ); + } + emitter._handlers[name] = emitter._handlers[name] || []; + emitter.emit("addHandler", name, handler); + + if (prepend) { + emitter._handlers[name].unshift(handler); + } else { + emitter._handlers[name].push(handler); + } + + return emitter; + }; + + private static _onceWrapper = function _onceWrapper(...args: unknown[]) { + if (!this.fired) { + this.fired = true; + this.emitter.off(this.event, this.wrappedHandler); + Reflect.apply(this.handler, this.emitter, args); + } + }; + + private static _removeHandler = ( + emitter: Emittr, + name: string, + handler: EventHandler, + ): Emittr => { + if (!isFun(handler)) { + throw new TypeError( + `Expected event handler to be a function, got ${typeof handler}`, + ); + } + if (isUndef(emitter._handlers[name]) || !emitter._handlers[name].length) { + return emitter; + } + let idx = -1; + if (emitter._handlers[name].length === 1) { + if ( + emitter._handlers[name][0] === handler || + (emitter._handlers[name][0] as OnceHandler).handler === handler + ) { + idx = 0; + handler = + (emitter._handlers[name][0] as OnceHandler).handler || + emitter._handlers[name][0]; + } + } else { + for (idx = emitter._handlers[name].length - 1; idx >= 0; idx--) { + if ( + emitter._handlers[name][idx] === handler || + (emitter._handlers[name][idx] as OnceHandler).handler === handler + ) { + handler = + (emitter._handlers[name][idx] as OnceHandler).handler || + emitter._handlers[name][idx]; + break; + } + } + } + if (idx === -1) { + return emitter; + } + + if (idx === 0) { + emitter._handlers[name].shift(); + } else { + emitter._handlers[name].splice(idx, 1); + } + + emitter.emit("removeHandler", name, handler); + return emitter; + }; + + setMaxHandlers(count: number): this { + if (!isNum(count) || count <= 0) { + throw new TypeError( + `Expected maxHandlers to be a positive number, got '${count}' of type ${typeof count}`, + ); + } + this._maxHandlers = count; + return this; + } + + getMaxHandlers(): number { + return this._maxHandlers; + } + + public emit(name: string, ...args: unknown[]): boolean { + if ( + typeof this._handlers[name] !== "object" || + !Array.isArray(this._handlers[name]) + ) { + return false; + } + Emittr._callEventHandlers(this, this._handlers[name], args); + return true; + } + + public on(name: string, handler: EventHandler): this { + Emittr._addHandler(this, name, handler); + return this; + } + + public prependOn(name: string, handler: EventHandler): this { + Emittr._addHandler(this, name, handler, true); + return this; + } + + public once(name: string, handler: EventHandler): this { + if (!isFun(handler)) { + throw new TypeError( + `Expected event handler to be a function, got ${typeof handler}`, + ); + } + Emittr._addHandler(this, name, this._wrapOnceHandler(name, handler)); + return this; + } + + public prependOnce(name: string, handler: EventHandler): this { + if (!isFun(handler)) { + throw new TypeError( + `Expected event handler to be a function, got ${typeof handler}`, + ); + } + Emittr._addHandler(this, name, this._wrapOnceHandler(name, handler), true); + return this; + } + + public off(name: string, handler: EventHandler): this { + Emittr._removeHandler(this, name, handler); + return this; + } + + public removeAllHandlers(): this { + const handlers = this._handlers; + this._handlers = Object.create(null); + const removeHandlers = handlers.removeHandler; + delete handlers.removeHandler; + let idx; + let eventName; + // eslint-disable-next-line guard-for-in,no-restricted-syntax + for (eventName in handlers) { + for (idx = handlers[eventName].length - 1; idx >= 0; idx--) { + Emittr._callEventHandlers(this, removeHandlers, [ + eventName, + (handlers[eventName][idx] as OnceHandler).handler || + handlers[eventName][idx], + ]); + } + } + return this; + } + + private _wrapOnceHandler(name: string, handler: EventHandler): OnceHandler { + const onceState: OnceHandlerState = { + fired: false, + handler, + wrappedHandler: undefined, + emitter: this, + event: name, + }; + const wrappedHandler: OnceHandler = Emittr._onceWrapper.bind(onceState); + onceState.wrappedHandler = wrappedHandler; + wrappedHandler.handler = handler; + wrappedHandler.event = name; + return wrappedHandler; + } +} \ No newline at end of file diff --git a/packages/components/scrollbar/custom-scrollbar/Loop.ts b/packages/components/scrollbar/custom-scrollbar/Loop.ts new file mode 100644 index 0000000000..883f6c82d6 --- /dev/null +++ b/packages/components/scrollbar/custom-scrollbar/Loop.ts @@ -0,0 +1,102 @@ +interface UpdatableItem { + _unmounted?: boolean; + update: () => unknown; + } + + export class RAFLoop { + /** + * @description List of targets to update + */ + private readonly targets: UpdatableItem[] = []; + + /** + * @description ID of requested animation frame. Valuable only if loop is active and has items to iterate. + */ + private animationFrameID = 0; + + /** + * @description Loop's state. + */ + private _isActive = false; + + /** + * @description Loop's state. + */ + public get isActive(): boolean { + return this._isActive; + } + + /** + * @description Start the loop if it wasn't yet. + */ + public start = (): this => { + if (!this._isActive && this.targets.length) { + this._isActive = true; + + if (this.animationFrameID) cancelAnimationFrame(this.animationFrameID); + this.animationFrameID = requestAnimationFrame(this.rafCallback); + } + + return this; + }; + + /** + * @description Stop the loop if is was active. + */ + public stop = (): this => { + if (this._isActive) { + this._isActive = false; + + if (this.animationFrameID) cancelAnimationFrame(this.animationFrameID); + this.animationFrameID = 0; + } + + return this; + }; + + /** + * @description Add target to the iteration list if it's not there. + */ + public addTarget = (target: UpdatableItem, silent = false): this => { + if (!this.targets.includes(target)) { + this.targets.push(target); + + if (this.targets.length === 1 && !silent) this.start(); + } + + return this; + }; + + /** + * @description Remove target from iteration list if it was there. + */ + public removeTarget = (target: UpdatableItem): this => { + const idx = this.targets.indexOf(target); + + if (idx !== -1) { + this.targets.splice(idx, 1); + + if (this.targets.length === 0) this.stop(); + } + + return this; + }; + + /** + * @description Callback that called each animation frame. + */ + private rafCallback = (): number => { + if (!this._isActive) { + return 0; + } + + for (let i = 0; i < this.targets.length; i++) { + if (!this.targets[i]._unmounted) this.targets[i].update(); + } + + this.animationFrameID = requestAnimationFrame(this.rafCallback); + return this.animationFrameID; + }; + } + + export default new RAFLoop(); \ No newline at end of file diff --git a/packages/components/scrollbar/custom-scrollbar/Scrollbar.tsx b/packages/components/scrollbar/custom-scrollbar/Scrollbar.tsx new file mode 100644 index 0000000000..dfe6c6305f --- /dev/null +++ b/packages/components/scrollbar/custom-scrollbar/Scrollbar.tsx @@ -0,0 +1,1522 @@ +/* eslint-disable no-bitwise */ +/* eslint-disable react/destructuring-assignment */ +/* eslint-disable react/sort-comp */ +import { cnb } from "cnbuilder"; +import * as React from "react"; +import { DraggableData } from "react-draggable"; +import { zoomLevel } from "zoom-level"; +import Emittr from "./Emittr"; +import Loop from "./Loop"; +import ScrollbarThumb, { ScrollbarThumbProps } from "./ScrollbarThumb"; +import ScrollbarTrack, { + ScrollbarTrackClickParameters, + ScrollbarTrackProps, +} from "./ScrollbarTrack"; +import defaultStyle from "./style"; +import { + AxisDirection, + ElementPropsWithElementRefAndRenderer, + ScrollState, + TrackClickBehavior, +} from "./types"; +import * as util from "./util"; +import { isBrowser, renderDivWithRenderer } from "./util"; + +let pageZoomLevel: number = isBrowser ? zoomLevel() : 1; +if (isBrowser) { + window.addEventListener( + "resize", + () => { + pageZoomLevel = zoomLevel(); + }, + { passive: true }, + ); +} + +export type ScrollbarProps = ElementPropsWithElementRefAndRenderer & { + createContext?: boolean; + + rtl?: boolean; + + momentum?: boolean; + native?: boolean; + mobileNative?: boolean; + + noScrollX?: boolean; + noScrollY?: boolean; + noScroll?: boolean; + + permanentTrackX?: boolean; + permanentTrackY?: boolean; + permanentTracks?: boolean; + + removeTracksWhenNotUsed?: boolean; + removeTrackYWhenNotUsed?: boolean; + removeTrackXWhenNotUsed?: boolean; + + minimalThumbSize?: number; + maximalThumbSize?: number; + minimalThumbXSize?: number; + maximalThumbXSize?: number; + minimalThumbYSize?: number; + maximalThumbYSize?: number; + + scrollbarWidth?: number; + fallbackScrollbarWidth?: number; + + scrollTop?: number; + scrollLeft?: number; + scrollDetectionThreshold?: number; + + translateContentSizesToHolder?: boolean; + translateContentSizeYToHolder?: boolean; + translateContentSizeXToHolder?: boolean; + + noDefaultStyles?: boolean; + + disableTracksMousewheelScrolling?: boolean; + disableTrackXMousewheelScrolling?: boolean; + disableTrackYMousewheelScrolling?: boolean; + + disableTracksWidthCompensation?: boolean; + disableTrackXWidthCompensation?: boolean; + disableTrackYWidthCompensation?: boolean; + + trackClickBehavior?: TrackClickBehavior; + + wrapperProps?: ElementPropsWithElementRefAndRenderer; + scrollerProps?: ElementPropsWithElementRefAndRenderer; + contentProps?: ElementPropsWithElementRefAndRenderer; + + trackXProps?: Pick< + ScrollbarTrackProps, + Exclude + >; + trackYProps?: Pick< + ScrollbarTrackProps, + Exclude + >; + + thumbXProps?: Pick< + ScrollbarThumbProps, + Exclude + >; + thumbYProps?: Pick< + ScrollbarThumbProps, + Exclude + >; + + onUpdate?: (scrollValues: ScrollState, prevScrollState: ScrollState) => void; + onScroll?: (scrollValues: ScrollState, prevScrollState: ScrollState) => void; + onScrollStart?: (scrollValues: ScrollState) => void; + onScrollStop?: (scrollValues: ScrollState) => void; +}; + +export type ScrollbarState = { + trackXVisible: boolean; + trackYVisible: boolean; + isRTL?: boolean; +}; + +export type ScrollbarContextValue = { parentScrollbar: Scrollbar | null }; + +export const ScrollbarContext: React.Context = + React.createContext({ + parentScrollbar: null, + } as ScrollbarContextValue); + +class Scrollbar extends React.Component { + // eslint-disable-next-line react/static-property-placement + static contextType = ScrollbarContext; + + // eslint-disable-next-line react/static-property-placement + static defaultProps = { + momentum: true, + + minimalThumbSize: 30, + + fallbackScrollbarWidth: 20, + + trackClickBehavior: TrackClickBehavior.JUMP, + + scrollDetectionThreshold: 100, + + wrapperProps: {}, + scrollerProps: {}, + contentProps: {}, + trackXProps: {}, + trackYProps: {}, + thumbXProps: {}, + thumbYProps: {}, + }; + + /** + * @description UUID identifying scrollbar instance + */ + public readonly id: string = ""; + + /** + * @description Reference to the holder HTMLDivElement or null if it wasn't rendered or native property is true + */ + public holderElement: HTMLDivElement | null = null; + + /** + * @description Reference to the wrapper HTMLDivElement or null if it wasn't rendered or native property is true + */ + public wrapperElement: HTMLDivElement | null = null; + + /** + * @description Reference to the HTMLDivElement that actually has browser's scrollbars + */ + public scrollerElement: HTMLDivElement | null = null; + + /** + * @description Reference to the content HTMLDivElement that contains component's children (and has browser's scrollbars) + */ + public contentElement: HTMLDivElement | null = null; + + /** + * @description Reference to the horizontal track HTMLDivElement or null if it wasn't rendered + */ + public trackXElement: HTMLDivElement | null = null; + + /** + * @description Reference to the vertical track HTMLDivElement or null if it wasn't rendered + */ + public trackYElement: HTMLDivElement | null = null; + + /** + * @description Reference to the horizontal thumb HTMLDivElement or null if it wasn't rendered + */ + public thumbXElement: HTMLDivElement | null = null; + + /** + * @description Reference to the vertical thumb HTMLDivElement or null if it wasn't rendered + */ + public thumbYElement: HTMLDivElement | null = null; + + public readonly eventEmitter: Emittr; + + /** + * @description Current ScrollState (cached) + */ + private scrollValues: ScrollState; + + private _scrollDetectionTO: number | null = null; + + constructor(props: ScrollbarProps) { + super(props); + + this.state = { + trackXVisible: false, + trackYVisible: false, + isRTL: props.rtl, + }; + + this.scrollValues = this.getScrollState(true); + + this.eventEmitter = new Emittr(15); + + if (props.onUpdate) this.eventEmitter.on("update", props.onUpdate); + if (props.onScroll) this.eventEmitter.on("scroll", props.onScroll); + if (props.onScrollStart) + this.eventEmitter.on("scrollStart", props.onScrollStart); + if (props.onScrollStop) + this.eventEmitter.on("scrollStop", props.onScrollStop); + + this.id = util.uuid(); + } + + // eslint-disable-next-line react/sort-comp + get scrollTop() { + if (this.scrollerElement) { + return this.scrollerElement.scrollTop; + } + + return 0; + } + + set scrollTop(top) { + if (this.scrollerElement) { + this.scrollerElement.scrollTop = top; + this.update(); + } + } + + get scrollLeft() { + if (this.scrollerElement) { + return this.scrollerElement.scrollLeft; + } + + return 0; + } + + set scrollLeft(left) { + if (this.scrollerElement) { + this.scrollerElement.scrollLeft = left; + } + } + + get scrollHeight() { + if (this.scrollerElement) { + return this.scrollerElement.scrollHeight; + } + + return 0; + } + + get scrollWidth() { + if (this.scrollerElement) { + return this.scrollerElement.scrollWidth; + } + + return 0; + } + + get clientHeight() { + if (this.scrollerElement) { + return this.scrollerElement.clientHeight; + } + + return 0; + } + + get clientWidth() { + if (this.scrollerElement) { + return this.scrollerElement.clientWidth; + } + + return 0; + } + + // eslint-disable-next-line react/sort-comp + public static calculateStyles( + props: ScrollbarProps, + state: ScrollbarState, + scrollValues: { + scrollXPossible?: boolean; + scrollYPossible: boolean; + zoomLevel: number; + }, + scrollbarWidth: number, + ) { + const useDefaultStyles = !props.noDefaultStyles; + + return { + holder: { + ...(useDefaultStyles && defaultStyle.holder), + position: "relative", + ...props.style, + } as React.CSSProperties, + wrapper: { + ...(useDefaultStyles && { + ...defaultStyle.wrapper, + ...(!props.disableTracksWidthCompensation && + !props.disableTrackYWidthCompensation && { + [state.isRTL ? "left" : "right"]: state.trackYVisible ? 10 : 0, + }), + ...(!props.disableTracksWidthCompensation && + !props.disableTrackXWidthCompensation && { + bottom: state.trackXVisible ? 10 : 0, + }), + }), + ...props.wrapperProps!.style, + position: "absolute", + overflow: "hidden", + } as React.CSSProperties, + content: { + ...(useDefaultStyles && defaultStyle.content), + ...(props.translateContentSizesToHolder || + props.translateContentSizeYToHolder || + props.translateContentSizeXToHolder + ? { + display: "table-cell", + } + : { + padding: 0.05, // needed to disable margin collapsing without flexboxes, other possible solutions here: https://stackoverflow.com/questions/19718634/how-to-disable-margin-collapsing + }), + ...(useDefaultStyles && + !( + props.translateContentSizesToHolder || + props.translateContentSizeYToHolder + ) && { + minHeight: "100%", + }), + ...(useDefaultStyles && + !( + props.translateContentSizesToHolder || + props.translateContentSizeXToHolder + ) && { + minWidth: "100%", + }), + ...props.contentProps!.style, + } as React.CSSProperties, + scroller: { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, + + paddingBottom: + !scrollbarWidth && scrollValues.scrollXPossible + ? props.fallbackScrollbarWidth + : undefined, + + [state.isRTL ? "paddingLeft" : "paddingRight"]: + !scrollbarWidth && scrollValues.scrollYPossible + ? props.fallbackScrollbarWidth + : undefined, + + ...props.scrollerProps!.style, + + ...(!util.isUndef(props.rtl) && { + direction: props.rtl ? "rtl" : "ltr", + }), + + ...(props.momentum && { WebkitOverflowScrolling: "touch" }), + + overflowY: scrollValues.scrollYPossible ? "scroll" : "hidden", + overflowX: scrollValues.scrollXPossible ? "scroll" : "hidden", + + marginBottom: scrollValues.scrollXPossible + ? -(scrollbarWidth || props.fallbackScrollbarWidth!) - + Number(scrollValues.zoomLevel !== 1) + : undefined, + [state.isRTL ? "marginLeft" : "marginRight"]: + scrollValues.scrollYPossible + ? -(scrollbarWidth || props.fallbackScrollbarWidth!) - + Number(scrollValues.zoomLevel !== 1) + : undefined, + } as React.CSSProperties, + trackX: { + ...(useDefaultStyles && defaultStyle.track.common), + ...(useDefaultStyles && defaultStyle.track.x), + ...props.trackXProps!.style, + ...(!state.trackXVisible && { display: "none" }), + } as React.CSSProperties, + trackY: { + ...(useDefaultStyles && defaultStyle.track.common), + ...(useDefaultStyles && defaultStyle.track.y), + ...(useDefaultStyles && { [state.isRTL ? "left" : "right"]: 0 }), + ...props.trackYProps!.style, + ...(!state.trackYVisible && { display: "none" }), + } as React.CSSProperties, + thumbX: { + ...(useDefaultStyles && defaultStyle.thumb.common), + ...(useDefaultStyles && defaultStyle.thumb.x), + ...props.thumbXProps!.style, + } as React.CSSProperties, + thumbY: { + ...(useDefaultStyles && defaultStyle.thumb.common), + ...(useDefaultStyles && defaultStyle.thumb.y), + ...props.thumbYProps!.style, + } as React.CSSProperties, + }; + } + + public componentDidMount(): void { + if (!this.scrollerElement) { + this.setState(() => { + throw new Error( + "scroller element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function.", + ); + }); + return; + } + + if (!this.contentElement) { + this.setState(() => { + throw new Error( + "content element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function.", + ); + }); + return; + } + + const { props } = this; + + if (!props.native && !props.mobileNative) { + // ToDo: move native state to the state so it can be synchronized + if (!this.holderElement) { + this.setState(() => { + throw new Error( + "holder element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function.", + ); + }); + return; + } + + if (!this.wrapperElement) { + this.setState(() => { + throw new Error( + "wrapper element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function.", + ); + }); + return; + } + } + + Loop.addTarget(this); + + if (!util.isUndef(props.scrollTop)) { + this.scrollerElement.scrollTop = props.scrollTop!; + } + + if (!util.isUndef(props.scrollLeft)) { + this.scrollerElement.scrollLeft = props.scrollLeft!; + } + + this.update(true); + } + + public componentWillUnmount(): void { + Loop.removeTarget(this); + } + + public componentDidUpdate( + prevProps: Readonly, + prevState: Readonly, + ): void { + if (!this.scrollerElement) { + return; + } + + const { props } = this; + + if (props.rtl !== prevProps.rtl && props.rtl !== this.state.isRTL) { + this.setState({ isRTL: props.rtl }); + } + + if (this.state.isRTL !== prevState.isRTL) { + this.update(); + } + + if ( + !util.isUndef(props.scrollTop) && + props.scrollTop !== this.scrollerElement.scrollTop + ) { + this.scrollerElement.scrollTop = props.scrollTop!; + } + + if ( + !util.isUndef(props.scrollLeft) && + props.scrollLeft !== this.scrollerElement.scrollLeft + ) { + this.scrollerElement.scrollLeft = props.scrollLeft!; + } + + if (prevProps.onUpdate !== props.onUpdate) { + if (prevProps.onUpdate) + this.eventEmitter.off("update", prevProps.onUpdate); + if (props.onUpdate) this.eventEmitter.on("update", props.onUpdate); + } + + if (prevProps.onScroll !== props.onScroll) { + if (prevProps.onScroll) + this.eventEmitter.off("scroll", prevProps.onScroll); + if (props.onScroll) this.eventEmitter.on("scroll", props.onScroll); + } + + if (prevProps.onScrollStart !== props.onScrollStart) { + if (prevProps.onScrollStart) + this.eventEmitter.off("scrollStart", prevProps.onScrollStart); + if (props.onScrollStart) + this.eventEmitter.on("scrollStart", props.onScrollStart); + } + + if (prevProps.onScrollStop !== props.onScrollStop) { + if (prevProps.onScrollStop) + this.eventEmitter.off("scrollStop", prevProps.onScrollStop); + if (props.onScrollStop) + this.eventEmitter.on("scrollStop", props.onScrollStop); + } + } + + /** + * @description Get current scroll-related values.
+ * If force if truthy - will recalculate them instead of returning cached values. + * + * @return ScrollState + */ + public getScrollState = (force = false): ScrollState => { + if (this.scrollValues && !force) { + return { ...this.scrollValues }; + } + + const scrollState: ScrollState = { + clientHeight: 0, + clientWidth: 0, + contentScrollHeight: 0, + contentScrollWidth: 0, + scrollHeight: 0, + scrollWidth: 0, + scrollTop: 0, + scrollLeft: 0, + scrollYBlocked: false, + scrollXBlocked: false, + scrollYPossible: false, + scrollXPossible: false, + trackYVisible: false, + trackXVisible: false, + zoomLevel: pageZoomLevel * 1, + isRTL: undefined, + }; + + const { props } = this; + + scrollState.isRTL = this.state.isRTL; + + scrollState.scrollYBlocked = props.noScroll! || props.noScrollY!; + scrollState.scrollXBlocked = props.noScroll! || props.noScrollX!; + + if (this.scrollerElement) { + scrollState.clientHeight = this.scrollerElement.clientHeight; + scrollState.clientWidth = this.scrollerElement.clientWidth; + + scrollState.scrollHeight = this.scrollerElement.scrollHeight; + scrollState.scrollWidth = this.scrollerElement.scrollWidth; + scrollState.scrollTop = this.scrollerElement.scrollTop; + scrollState.scrollLeft = this.scrollerElement.scrollLeft; + + scrollState.scrollYPossible = + !scrollState.scrollYBlocked && + scrollState.scrollHeight > scrollState.clientHeight; + scrollState.scrollXPossible = + !scrollState.scrollXBlocked && + scrollState.scrollWidth > scrollState.clientWidth; + + scrollState.trackYVisible = + scrollState.scrollYPossible || + props.permanentTracks! || + props.permanentTrackY!; + scrollState.trackXVisible = + scrollState.scrollXPossible || + props.permanentTracks! || + props.permanentTrackX!; + } + + if (this.contentElement) { + scrollState.contentScrollHeight = this.contentElement.scrollHeight; + scrollState.contentScrollWidth = this.contentElement.scrollWidth; + } + + return scrollState; + }; + + /** + * @description Scroll to top border + */ + public scrollToTop = (): this => { + if (this.scrollerElement) { + this.scrollerElement.scrollTop = 0; + } + + return this; + }; + + /** + * @description Scroll to left border + */ + public scrollToLeft = (): this => { + if (this.scrollerElement) { + this.scrollerElement.scrollLeft = 0; + } + + return this; + }; + + /** + * @description Scroll to bottom border + */ + public scrollToBottom = (): this => { + if (this.scrollerElement) { + this.scrollerElement.scrollTop = + this.scrollerElement.scrollHeight - this.scrollerElement.clientHeight; + } + + return this; + }; + + /** + * @description Scroll to right border + */ + public scrollToRight = (): this => { + if (this.scrollerElement) { + this.scrollerElement.scrollLeft = + this.scrollerElement.scrollWidth - this.scrollerElement.clientWidth; + } + + return this; + }; + + /** + * @description Set the scrolls at given coordinates.
+ * If coordinate is undefined - current scroll value will persist. + */ + public scrollTo = (x?: number, y?: number): this => { + if (this.scrollerElement) { + if (util.isNum(x)) this.scrollerElement.scrollLeft = x!; + if (util.isNum(y)) this.scrollerElement.scrollTop = y!; + } + + return this; + }; + + /** + * @description Center the viewport at given coordinates.
+ * If coordinate is undefined - current scroll value will persist. + */ + public centerAt = (x?: number, y?: number): this => { + if (this.scrollerElement) { + if (util.isNum(x)) + this.scrollerElement.scrollLeft = + x - this.scrollerElement.clientWidth / 2; + if (util.isNum(y)) + this.scrollerElement.scrollTop = + y - this.scrollerElement.clientHeight / 2; + } + + return this; + }; + + public update = (force = false): ScrollState | void => { + if (!this.scrollerElement) { + return; + } + + // autodetect direction if not defined + if (util.isUndef(this.state.isRTL)) { + this.setState({ + isRTL: getComputedStyle(this.scrollerElement).direction === "rtl", + }); + + return this.getScrollState(); + } + + const scrollState: ScrollState = this.getScrollState(true); + const prevScrollState: ScrollState = { ...this.scrollValues }; + const { props } = this; + + let bitmask = 0; + + if (!force) { + if (prevScrollState.clientHeight !== scrollState.clientHeight) + bitmask |= Math.trunc(1); + if (prevScrollState.clientWidth !== scrollState.clientWidth) + bitmask |= 1 << 1; + if (prevScrollState.scrollHeight !== scrollState.scrollHeight) + bitmask |= 1 << 2; + if (prevScrollState.scrollWidth !== scrollState.scrollWidth) + bitmask |= 1 << 3; + if (prevScrollState.scrollTop !== scrollState.scrollTop) + bitmask |= 1 << 4; + if (prevScrollState.scrollLeft !== scrollState.scrollLeft) + bitmask |= 1 << 5; + if (prevScrollState.scrollYBlocked !== scrollState.scrollYBlocked) + bitmask |= 1 << 6; + if (prevScrollState.scrollXBlocked !== scrollState.scrollXBlocked) + bitmask |= 1 << 7; + if (prevScrollState.scrollYPossible !== scrollState.scrollYPossible) + bitmask |= 1 << 8; + if (prevScrollState.scrollXPossible !== scrollState.scrollXPossible) + bitmask |= 1 << 9; + if (prevScrollState.trackYVisible !== scrollState.trackYVisible) + bitmask |= 1 << 10; + if (prevScrollState.trackXVisible !== scrollState.trackXVisible) + bitmask |= 1 << 11; + if (prevScrollState.isRTL !== scrollState.isRTL) bitmask |= 1 << 12; + + if ( + prevScrollState.contentScrollHeight !== scrollState.contentScrollHeight + ) + bitmask |= 1 << 13; + if (prevScrollState.contentScrollWidth !== scrollState.contentScrollWidth) + bitmask |= 1 << 14; + + if (prevScrollState.zoomLevel !== scrollState.zoomLevel) + bitmask |= 1 << 15; + + // if not forced and nothing has changed - skip this update + if (bitmask === 0) { + return prevScrollState; + } + } else { + bitmask = 0b111_1111_1111_1111; + } + + if (!props.native && this.holderElement) { + if ( + bitmask & (1 << 13) && + (props.translateContentSizesToHolder || + props.translateContentSizeYToHolder) + ) { + this.holderElement.style.height = `${scrollState.contentScrollHeight}px`; + } + + if ( + bitmask & (1 << 14) && + (props.translateContentSizesToHolder || + props.translateContentSizeXToHolder) + ) { + this.holderElement.style.width = `${scrollState.contentScrollWidth}px`; + } + + if ( + props.translateContentSizesToHolder || + props.translateContentSizeYToHolder || + props.translateContentSizeXToHolder + ) { + if ( + (!scrollState.clientHeight && scrollState.contentScrollHeight) || + (!scrollState.clientWidth && scrollState.contentScrollWidth) + ) { + return; + } + } + } + + // if scrollbars visibility has changed + if (bitmask & (1 << 10) || bitmask & (1 << 11)) { + prevScrollState.scrollYBlocked = scrollState.scrollYBlocked; + prevScrollState.scrollXBlocked = scrollState.scrollXBlocked; + prevScrollState.scrollYPossible = scrollState.scrollYPossible; + prevScrollState.scrollXPossible = scrollState.scrollXPossible; + + if (this.trackYElement && bitmask & (1 << 10)) { + this.trackYElement.style.display = scrollState.trackYVisible + ? "" + : "none"; + } + + if (this.trackXElement && bitmask & (1 << 11)) { + this.trackXElement.style.display = scrollState.trackXVisible + ? "" + : "none"; + } + + this.scrollValues = prevScrollState; + this.setState({ + trackYVisible: (this.scrollValues.trackYVisible = + scrollState.trackYVisible)!, + trackXVisible: (this.scrollValues.trackXVisible = + scrollState.trackXVisible)!, + }); + + return; + } + + (props.native ? this.updaterNative : this.updaterCustom)( + bitmask, + scrollState, + ); + + this.scrollValues = scrollState; + + if (!props.native && bitmask & (1 << 15)) { + util.getScrollbarWidth(true); + this.forceUpdate(); + } + + this.eventEmitter.emit("update", { ...scrollState }, prevScrollState); + + if (bitmask & (1 << 4) || bitmask & (1 << 5)) + this.eventEmitter.emit("scroll", { ...scrollState }, prevScrollState); + + return this.scrollValues; + }; + + // eslint-disable-next-line react/sort-comp + public render(): React.ReactNode { + const { + createContext, + rtl, + native, + mobileNative, + momentum, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + noDefaultStyles, + + disableTracksMousewheelScrolling, + disableTrackXMousewheelScrolling, + disableTrackYMousewheelScrolling, + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + disableTracksWidthCompensation, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + disableTrackXWidthCompensation, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + disableTrackYWidthCompensation, + + noScrollX, + noScrollY, + noScroll, + + permanentTrackX, + permanentTrackY, + permanentTracks, + + removeTracksWhenNotUsed, + removeTrackYWhenNotUsed, + removeTrackXWhenNotUsed, + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + minimalThumbSize, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + maximalThumbSize, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + minimalThumbXSize, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + maximalThumbXSize, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + minimalThumbYSize, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + maximalThumbYSize, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + fallbackScrollbarWidth, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + scrollTop, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + scrollLeft, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + trackClickBehavior, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + scrollDetectionThreshold, + + wrapperProps: propsWrapperProps, + scrollerProps: propsScrollerProps, + contentProps: propsContentProps, + trackXProps: propsTrackXProps, + trackYProps: propsTrackYProps, + thumbXProps: propsThumbXProps, + thumbYProps: propsThumbYProps, + + scrollbarWidth: propsScrollbarWidth, + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + elementRef, + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onUpdate, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onScroll, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onScrollStart, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onScrollStop, + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + translateContentSizesToHolder, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + translateContentSizeYToHolder, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + translateContentSizeXToHolder, + + children, + + ...propsHolderProps + } = this.props as ScrollbarProps; + + const scrollbarWidth = !util.isUndef(propsScrollbarWidth) + ? propsScrollbarWidth + : util.getScrollbarWidth() || 0; + + if (native || (!scrollbarWidth && mobileNative)) { + this.elementRefHolder(null); + this.elementRefWrapper(null); + this.elementRefTrackX(null); + this.elementRefTrackY(null); + this.elementRefThumbX(null); + this.elementRefThumbY(null); + + const contentProps = { + ...propsContentProps, + key: "ScrollbarsCustom-Content", + className: cnb( + "ScrollbarsCustom-Content", + propsContentProps!.className, + ), + children, + } as ElementPropsWithElementRefAndRenderer; + + const scrollerProps = { + ...propsHolderProps, + className: cnb( + "ScrollbarsCustom native", + this.state.trackYVisible && "trackYVisible", + this.state.trackXVisible && "trackXVisible", + this.state.isRTL && "rtl", + propsHolderProps.className, + ), + style: { + ...propsHolderProps.style, + ...(!util.isUndef(rtl) && { + direction: rtl ? "rtl" : "ltr", + }), + + ...(momentum && { WebkitOverflowScrolling: "touch" }), + overflowX: + noScroll || noScrollX + ? "hidden" + : permanentTracks || permanentTrackX + ? "scroll" + : "auto", + overflowY: + noScroll || noScrollY + ? "hidden" + : permanentTracks || permanentTrackY + ? "scroll" + : "auto", + }, + onScroll: this.handleScrollerScroll, + children: renderDivWithRenderer(contentProps, this.elementRefContent), + renderer: propsScrollerProps!.renderer, + elementRef: propsScrollerProps!.elementRef, + } as ElementPropsWithElementRefAndRenderer; + + return renderDivWithRenderer(scrollerProps, this.elementRefScroller); + } + + const styles = Scrollbar.calculateStyles( + this.props, + this.state, + this.scrollValues, + scrollbarWidth, + ); + + const holderChildren = [] as Array; + + const contentProps = { + ...propsContentProps, + key: "ScrollbarsCustom-Content", + className: cnb("ScrollbarsCustom-Content", propsContentProps!.className), + style: styles.content, + children: createContext ? ( + // eslint-disable-next-line react/jsx-no-constructed-context-values + + {children} + + ) : ( + children + ), + } as ElementPropsWithElementRefAndRenderer; + + const scrollerProps = { + ...propsScrollerProps, + key: "ScrollbarsCustom-Scroller", + className: cnb( + "ScrollbarsCustom-Scroller", + propsScrollerProps!.className, + ), + style: styles.scroller, + children: renderDivWithRenderer(contentProps, this.elementRefContent), + onScroll: this.handleScrollerScroll, + } as ElementPropsWithElementRefAndRenderer; + + const wrapperProps = { + ...propsWrapperProps, + key: "ScrollbarsCustom-Wrapper", + className: cnb("ScrollbarsCustom-Wrapper", propsWrapperProps!.className), + style: styles.wrapper, + children: renderDivWithRenderer(scrollerProps, this.elementRefScroller), + } as ElementPropsWithElementRefAndRenderer; + + holderChildren.push( + renderDivWithRenderer(wrapperProps, this.elementRefWrapper), + ); + + if ( + this.state.trackYVisible || + (!removeTracksWhenNotUsed && !removeTrackYWhenNotUsed) + ) { + const thumbYProps = { + ...propsThumbYProps, + // key: "ScrollbarsCustom-ThumbY", + style: styles.thumbY, + elementRef: this.elementRefThumbY, + onDrag: this.handleThumbYDrag, + onDragEnd: this.handleThumbYDragEnd, + axis: AxisDirection.Y, + } as ScrollbarThumbProps; + + const trackYProps = { + ...propsTrackYProps, + // key: "ScrollbarsCustom-TrackY", + style: styles.trackY, + elementRef: this.elementRefTrackY, + onClick: this.handleTrackYClick, + ...((disableTracksMousewheelScrolling || + disableTrackYMousewheelScrolling) && { + onWheel: this.handleTrackYMouseWheel, + }), + axis: AxisDirection.Y, + } as ScrollbarTrackProps; + + trackYProps.children = ( + + ); + holderChildren.push( + , + ); + } else { + this.elementRefTrackY(null); + this.elementRefThumbY(null); + } + + if ( + this.state.trackXVisible || + (!removeTracksWhenNotUsed && !removeTrackXWhenNotUsed) + ) { + const thumbXProps = { + ...propsThumbXProps, + // key: "ScrollbarsCustom-ThumbX", + style: styles.thumbX, + elementRef: this.elementRefThumbX, + onDrag: this.handleThumbXDrag, + onDragEnd: this.handleThumbXDragEnd, + axis: AxisDirection.X, + } as ScrollbarThumbProps; + + const trackXProps = { + ...propsTrackXProps, + // key: "ScrollbarsCustom-TrackX", + style: styles.trackX, + elementRef: this.elementRefTrackX, + onClick: this.handleTrackXClick, + ...((disableTracksMousewheelScrolling || + disableTrackXMousewheelScrolling) && { + onWheel: this.handleTrackXMouseWheel, + }), + axis: AxisDirection.X, + } as ScrollbarTrackProps; + + trackXProps.children = ( + + ); + holderChildren.push( + , + ); + } else { + this.elementRefTrackX(null); + this.elementRefThumbX(null); + } + + const holderProps = { + ...propsHolderProps, + className: cnb( + "ScrollbarsCustom", + this.state.trackYVisible && "trackYVisible", + this.state.trackXVisible && "trackXVisible", + this.state.isRTL && "rtl", + propsHolderProps.className, + ), + style: styles.holder, + children: holderChildren, + } as ElementPropsWithElementRefAndRenderer; + + return renderDivWithRenderer(holderProps, this.elementRefHolder); + } + + // eslint-disable-next-line class-methods-use-this + private updaterNative = (): boolean => { + // just for future + return true; + }; + + private updaterCustom = ( + bitmask: number, + scrollValues: ScrollState, + ): boolean => { + const { props } = this; + + if (this.trackYElement) { + if ( + this.thumbYElement && + (bitmask & Math.trunc(1) || + bitmask & (1 << 2) || + bitmask & (1 << 4) || + bitmask & (1 << 6) || + bitmask & (1 << 8)) + ) { + if (scrollValues.scrollYPossible) { + const trackInnerSize = util.getInnerHeight(this.trackYElement); + + const thumbSize = util.calcThumbSize( + scrollValues.scrollHeight, + scrollValues.clientHeight, + trackInnerSize, + props.minimalThumbYSize || props.minimalThumbSize, + props.maximalThumbYSize || props.maximalThumbSize, + ); + const thumbOffset = util.calcThumbOffset( + scrollValues.scrollHeight, + scrollValues.clientHeight, + trackInnerSize, + thumbSize, + scrollValues.scrollTop, + ); + + this.thumbYElement.style.transform = `translateY(${thumbOffset}px)`; + this.thumbYElement.style.height = `${thumbSize}px`; + this.thumbYElement.style.display = ""; + } else { + this.thumbYElement.style.transform = ""; + this.thumbYElement.style.height = "0px"; + this.thumbYElement.style.display = "none"; + } + } + } + + if (this.trackXElement) { + if ( + this.thumbXElement && + (bitmask & (1 << 1) || + bitmask & (1 << 3) || + bitmask & (1 << 5) || + bitmask & (1 << 7) || + bitmask & (1 << 9) || + bitmask & (1 << 12)) + ) { + if (scrollValues.scrollXPossible) { + const trackInnerSize = util.getInnerWidth(this.trackXElement); + const thumbSize = util.calcThumbSize( + scrollValues.scrollWidth, + scrollValues.clientWidth, + trackInnerSize, + props.minimalThumbXSize || props.minimalThumbSize, + props.maximalThumbXSize || props.maximalThumbSize, + ); + let thumbOffset = util.calcThumbOffset( + scrollValues.scrollWidth, + scrollValues.clientWidth, + trackInnerSize, + thumbSize, + scrollValues.scrollLeft, + ); + + if (this.state.isRTL && util.shouldReverseRtlScroll()) { + thumbOffset += trackInnerSize - thumbSize; + } + + this.thumbXElement.style.transform = `translateX(${thumbOffset}px)`; + this.thumbXElement.style.width = `${thumbSize}px`; + this.thumbXElement.style.display = ""; + } else { + this.thumbXElement.style.transform = ""; + this.thumbXElement.style.width = "0px"; + this.thumbXElement.style.display = "none"; + } + } + } + + return true; + }; + + private elementRefHolder = (ref: HTMLDivElement | null) => { + this.holderElement = ref; + if (util.isFun(this.props.elementRef)) { + this.props.elementRef(ref); + } + }; + + private elementRefWrapper = (ref: HTMLDivElement | null) => { + this.wrapperElement = ref; + if (util.isFun(this.props.wrapperProps!.elementRef)) { + this.props.wrapperProps!.elementRef(ref); + } + }; + + private elementRefScroller = (ref: HTMLDivElement | null) => { + this.scrollerElement = ref; + if (util.isFun(this.props.scrollerProps!.elementRef)) { + this.props.scrollerProps!.elementRef(ref); + } + }; + + private elementRefContent = (ref: HTMLDivElement | null) => { + this.contentElement = ref; + if (util.isFun(this.props.contentProps!.elementRef)) { + this.props.contentProps!.elementRef(ref); + } + }; + + private elementRefTrackX = (ref: HTMLDivElement | null) => { + if (!ref && this.trackXElement) return; + this.trackXElement = ref; + + if (util.isFun(this.props.trackXProps!.elementRef)) { + this.props.trackXProps!.elementRef(ref); + } + }; + + private elementRefTrackY = (ref: HTMLDivElement | null) => { + if (!ref && this.trackYElement) return; + this.trackYElement = ref; + if (util.isFun(this.props.trackYProps!.elementRef)) { + this.props.trackYProps!.elementRef(ref); + } + }; + + private elementRefThumbX = (ref: HTMLDivElement | null) => { + if (!ref && this.thumbXElement) return; + this.thumbXElement = ref; + if (util.isFun(this.props.thumbXProps!.elementRef)) { + this.props.thumbXProps!.elementRef(ref); + } + }; + + private elementRefThumbY = (ref: HTMLDivElement | null) => { + if (!ref && this.thumbYElement) return; + this.thumbYElement = ref; + if (util.isFun(this.props.thumbYProps!.elementRef)) { + this.props.thumbYProps!.elementRef(ref); + } + }; + + private handleTrackXClick = ( + ev: MouseEvent, + values: ScrollbarTrackClickParameters, + ): void => { + if (this.props.trackXProps!.onClick) { + this.props.trackXProps!.onClick(ev, values); + } + + if ( + !this.scrollerElement || + !this.trackXElement || + !this.thumbXElement || + !this.scrollValues || + !this.scrollValues.scrollXPossible + ) { + return; + } + + this._scrollDetection(); + + const thumbSize = this.thumbXElement.clientWidth; + const trackInnerSize = util.getInnerWidth(this.trackXElement); + const thumbOffset = + (this.scrollValues.isRTL && util.shouldReverseRtlScroll() + ? values.offset + thumbSize / 2 - trackInnerSize + : values.offset - thumbSize / 2) - + (Number.parseFloat(getComputedStyle(this.trackXElement).paddingLeft) || + 0); + + let target = util.calcScrollForThumbOffset( + this.scrollValues.scrollWidth, + this.scrollValues.clientWidth, + trackInnerSize, + thumbSize, + thumbOffset, + ); + + if (this.props.trackClickBehavior === TrackClickBehavior.STEP) { + target = ( + this.scrollValues.isRTL + ? this.scrollValues.scrollLeft > target + : this.scrollValues.scrollLeft < target + ) + ? this.scrollValues.scrollLeft + this.scrollValues.clientWidth + : this.scrollValues.scrollLeft - this.scrollValues.clientWidth; + } + + this.scrollerElement.scrollLeft = target; + }; + + private handleTrackYClick = ( + ev: MouseEvent, + values: ScrollbarTrackClickParameters, + ): void => { + if (this.props.trackYProps!.onClick) + this.props.trackYProps!.onClick(ev, values); + + if ( + !this.scrollerElement || + !this.trackYElement || + !this.thumbYElement || + !this.scrollValues || + !this.scrollValues.scrollYPossible + ) { + return; + } + + this._scrollDetection(); + + const thumbSize = this.thumbYElement.clientHeight; + const target = + util.calcScrollForThumbOffset( + this.scrollValues.scrollHeight, + this.scrollValues.clientHeight, + util.getInnerHeight(this.trackYElement), + thumbSize, + values.offset - thumbSize / 2, + ) - + (Number.parseFloat(getComputedStyle(this.trackYElement).paddingTop) || 0); + + if (this.props.trackClickBehavior === TrackClickBehavior.JUMP) { + this.scrollerElement.scrollTop = target; + } else { + this.scrollerElement.scrollTop = + this.scrollValues.scrollTop < target + ? this.scrollValues.scrollTop + this.scrollValues.clientHeight + : this.scrollValues.scrollTop - this.scrollValues.clientHeight; + } + }; + + private handleTrackYMouseWheel = (ev: React.WheelEvent) => { + const { props } = this; + + if (props.trackYProps && props.trackYProps.onWheel) { + props.trackYProps.onWheel(ev); + } + + if ( + props.disableTracksMousewheelScrolling || + props.disableTrackYMousewheelScrolling + ) { + return; + } + + this._scrollDetection(); + + if (!this.scrollerElement || this.scrollValues.scrollYBlocked) { + return; + } + + this.scrollTop += ev.deltaY; + }; + + private handleTrackXMouseWheel = (ev: React.WheelEvent) => { + const { props } = this; + + if (props.trackXProps && props.trackXProps.onWheel) { + props.trackXProps.onWheel(ev); + } + + if ( + props.disableTracksMousewheelScrolling || + props.disableTrackXMousewheelScrolling + ) { + return; + } + + this._scrollDetection(); + + if (!this.scrollerElement || this.scrollValues.scrollXBlocked) { + return; + } + + this.scrollLeft += ev.deltaX; + }; + + private handleThumbXDrag = (data: DraggableData): void => { + if ( + !this.trackXElement || + !this.thumbXElement || + !this.scrollerElement || + !this.scrollValues || + !this.scrollValues.scrollXPossible + ) { + return; + } + this._scrollDetection(); + + const trackRect: ClientRect = this.trackXElement.getBoundingClientRect(); + const styles: CSSStyleDeclaration = getComputedStyle(this.trackXElement); + const paddingLeft: number = Number.parseFloat(styles.paddingLeft) || 0; + const paddingRight: number = Number.parseFloat(styles.paddingRight) || 0; + const trackInnerSize = trackRect.width - paddingLeft - paddingRight; + const thumbSize = this.thumbXElement.clientWidth; + const offset = + this.scrollValues.isRTL && util.shouldReverseRtlScroll() + ? data.x + thumbSize - trackInnerSize + paddingLeft + : data.lastX - paddingLeft; + + this.scrollerElement.scrollLeft = util.calcScrollForThumbOffset( + this.scrollValues.scrollWidth, + this.scrollValues.clientWidth, + trackInnerSize, + thumbSize, + offset, + ); + + if (this.props.thumbXProps?.onDrag) { + this.props.thumbXProps.onDrag(data); + } + }; + + private handleThumbXDragEnd = (data: DraggableData): void => { + this.handleThumbXDrag(data); + + if (this.props.thumbXProps?.onDragEnd) { + this.props.thumbXProps.onDragEnd(data); + } + }; + + private handleThumbYDrag = (data: DraggableData): void => { + if ( + !this.scrollerElement || + !this.trackYElement || + !this.thumbYElement || + !this.scrollValues || + !this.scrollValues.scrollYPossible + ) { + return; + } + this._scrollDetection(); + + const trackRect: ClientRect = this.trackYElement.getBoundingClientRect(); + const styles: CSSStyleDeclaration = getComputedStyle(this.trackYElement); + const paddingTop: number = Number.parseFloat(styles.paddingTop) || 0; + const paddingBottom: number = Number.parseFloat(styles.paddingBottom) || 0; + const trackInnerSize = trackRect.height - paddingTop - paddingBottom; + const thumbSize = this.thumbYElement.clientHeight; + const offset = data.y - paddingTop; + + this.scrollerElement.scrollTop = util.calcScrollForThumbOffset( + this.scrollValues.scrollHeight, + this.scrollValues.clientHeight, + trackInnerSize, + thumbSize, + offset, + ); + + if (this.props.thumbYProps?.onDrag) { + this.props.thumbYProps.onDrag(data); + } + }; + + private handleThumbYDragEnd = (data: DraggableData): void => { + this.handleThumbYDrag(data); + + if (this.props.thumbYProps?.onDragEnd) { + this.props.thumbYProps.onDragEnd(data); + } + }; + + private handleScrollerScroll = () => { + this._scrollDetection(); + }; + + private _scrollDetection = () => { + if (!this._scrollDetectionTO) { + this.eventEmitter.emit("scrollStart", this.getScrollState()); + } else if (isBrowser) { + window.clearTimeout(this._scrollDetectionTO); + } + + this._scrollDetectionTO = isBrowser + ? window.setTimeout( + this._scrollDetectionCallback, + this.props.scrollDetectionThreshold || 0, + ) + : null; + }; + + private _scrollDetectionCallback = () => { + this._scrollDetectionTO = null; + this.eventEmitter.emit("scrollStop", this.getScrollState()); + }; +} + +export default Scrollbar; \ No newline at end of file diff --git a/packages/components/scrollbar/custom-scrollbar/ScrollbarThumb.tsx b/packages/components/scrollbar/custom-scrollbar/ScrollbarThumb.tsx new file mode 100644 index 0000000000..296b688124 --- /dev/null +++ b/packages/components/scrollbar/custom-scrollbar/ScrollbarThumb.tsx @@ -0,0 +1,234 @@ +/* eslint-disable @typescript-eslint/no-useless-constructor */ +import { cnb } from "cnbuilder"; +import React from "react"; +import { DraggableCore, DraggableData, DraggableEvent } from "react-draggable"; +import { AxisDirection, ElementPropsWithElementRefAndRenderer } from "./types"; +import { isBrowser, isFun, isUndef, renderDivWithRenderer } from "./util"; + +export type DragCallbackData = Pick< + DraggableData, + Exclude +>; + +export type ScrollbarThumbProps = ElementPropsWithElementRefAndRenderer & { + axis: AxisDirection; + + onDrag?: (data: DragCallbackData) => void; + onDragStart?: (data: DragCallbackData) => void; + onDragEnd?: (data: DragCallbackData) => void; + + ref?: (ref: ScrollbarThumb | null) => void; +}; + +class ScrollbarThumb extends React.Component { + private static selectStartReplacer = () => false; + + public element: HTMLDivElement | null = null; + + public initialOffsetX = 0; + + public initialOffsetY = 0; + + private prevUserSelect: string; + + private prevOnSelectStart: ((ev: Event) => boolean) | null; + + private elementRefHack = React.createRef(); + + public lastDragData: DragCallbackData = { + x: 0, + y: 0, + deltaX: 0, + deltaY: 0, + lastX: 0, + lastY: 0, + }; + + public componentDidMount(): void { + if (!this.element) { + this.setState(() => { + // throw new Error( + // " Element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function.", + // ); + }); + } + } + + public componentWillUnmount(): void { + this.handleOnDragStop(); + + this.elementRef(null); + } + + public handleOnDragStart = (ev: DraggableEvent, data: DraggableData) => { + if (!this.element) { + this.handleOnDragStop(ev, data); + return; + } + + if (isBrowser) { + this.prevUserSelect = document.body.style.userSelect; + document.body.style.userSelect = "none"; + + this.prevOnSelectStart = document.onselectstart; + document.addEventListener( + "selectstart", + ScrollbarThumb.selectStartReplacer, + ); + } + + if (this.props.onDragStart) { + this.props.onDragStart( + (this.lastDragData = { + x: data.x - this.initialOffsetX, + y: data.y - this.initialOffsetY, + lastX: data.lastX - this.initialOffsetX, + lastY: data.lastY - this.initialOffsetY, + deltaX: data.deltaX, + deltaY: data.deltaY, + }), + ); + } + + this.element.classList.add("dragging"); + }; + + public handleOnDrag = (ev: DraggableEvent, data: DraggableData) => { + if (!this.element) { + this.handleOnDragStop(ev, data); + return; + } + + if (this.props.onDrag) { + this.props.onDrag( + (this.lastDragData = { + x: data.x - this.initialOffsetX, + y: data.y - this.initialOffsetY, + lastX: data.lastX - this.initialOffsetX, + lastY: data.lastY - this.initialOffsetY, + deltaX: data.deltaX, + deltaY: data.deltaY, + }), + ); + } + }; + + public handleOnDragStop = (ev?: DraggableEvent, data?: DraggableData) => { + const resultData = data + ? { + x: data.x - this.initialOffsetX, + y: data.y - this.initialOffsetY, + lastX: data.lastX - this.initialOffsetX, + lastY: data.lastY - this.initialOffsetY, + deltaX: data.deltaX, + deltaY: data.deltaY, + } + : this.lastDragData; + + if (this.props.onDragEnd) this.props.onDragEnd(resultData); + + if (this.element) this.element.classList.remove("dragging"); + + if (isBrowser) { + document.body.style.userSelect = this.prevUserSelect; + + if (this.prevOnSelectStart) { + document.addEventListener("selectstart", this.prevOnSelectStart); + } + + this.prevOnSelectStart = null; + } + + this.initialOffsetX = 0; + this.initialOffsetY = 0; + this.lastDragData = { + x: 0, + y: 0, + deltaX: 0, + deltaY: 0, + lastX: 0, + lastY: 0, + }; + }; + + public handleOnMouseDown = (ev: MouseEvent) => { + if (!this.element) { + return; + } + + ev.preventDefault(); + ev.stopPropagation(); + + if (!isUndef(ev.offsetX)) { + /* istanbul ignore next */ + this.initialOffsetX = ev.offsetX; + /* istanbul ignore next */ + this.initialOffsetY = ev.offsetY; + } else { + const rect: DOMRect = this.element.getBoundingClientRect(); + this.initialOffsetX = + (ev.clientX || (ev as unknown as TouchEvent).touches[0].clientX) - + rect.left; + this.initialOffsetY = + (ev.clientY || (ev as unknown as TouchEvent).touches[0].clientY) - + rect.top; + } + }; + + private elementRef = (ref: HTMLDivElement | null): void => { + if (!ref && this.element) return; + + if (isFun(this.props.elementRef)) this.props.elementRef(ref); + this.element = ref; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.elementRefHack.current = ref; + }; + + public render(): React.ReactElement | null { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + elementRef, + + axis, + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onDrag, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onDragEnd, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onDragStart, + + ...props + } = this.props as ScrollbarThumbProps; + + props.className = cnb( + "ScrollbarsCustom-Thumb", + axis === AxisDirection.X + ? "ScrollbarsCustom-ThumbX" + : "ScrollbarsCustom-ThumbY", + props.className, + ); + + if (props.renderer) { + (props as ScrollbarThumbProps).axis = axis; + } + + return ( + + {renderDivWithRenderer(props, this.elementRef)} + + ); + } +} + +export default ScrollbarThumb; \ No newline at end of file diff --git a/packages/components/scrollbar/custom-scrollbar/ScrollbarTrack.tsx b/packages/components/scrollbar/custom-scrollbar/ScrollbarTrack.tsx new file mode 100644 index 0000000000..4e692589fb --- /dev/null +++ b/packages/components/scrollbar/custom-scrollbar/ScrollbarTrack.tsx @@ -0,0 +1,109 @@ +import { cnb } from "cnbuilder"; +import React from "react"; +import { AxisDirection, ElementPropsWithElementRefAndRenderer } from "./types"; +import { isFun, isUndef, renderDivWithRenderer } from "./util"; + +export interface ScrollbarTrackClickParameters { + axis: AxisDirection; + offset: number; +} + +export type ScrollbarTrackProps = ElementPropsWithElementRefAndRenderer & { + axis: AxisDirection; + + onClick?: (ev: MouseEvent, values: ScrollbarTrackClickParameters) => void; + + ref?: (ref: ScrollbarTrack | null) => void; +}; + +class ScrollbarTrack extends React.Component { + public element: HTMLDivElement | null = null; + + public componentDidMount(): void { + if (!this.element) { + this.setState(() => { + // throw new Error( + // "Element was not created. Possibly you haven't provided HTMLDivElement to renderer's `elementRef` function.", + // ); + }); + return; + } + + this.element?.addEventListener("click", this.handleClick); + } + + public componentWillUnmount(): void { + if (this.element) { + this.element.removeEventListener("click", this.handleClick); + this.element = null; + + this.elementRef(null); + } + } + + private elementRef = (ref: HTMLDivElement | null): void => { + if (!ref && this.element) return; + if (isFun(this.props.elementRef)) this.props.elementRef(ref); + this.element = ref; + }; + + private handleClick = (ev: MouseEvent) => { + if (!ev || !this.element || ev.button !== 0) { + return; + } + + if (isFun(this.props.onClick) && ev.target === this.element) { + if (!isUndef(ev.offsetX)) { + this.props.onClick(ev, { + axis: this.props.axis, + offset: this.props.axis === AxisDirection.X ? ev.offsetX : ev.offsetY, + }); + } else { + // support for old browsers + /* istanbul ignore next */ + const rect: ClientRect = this.element.getBoundingClientRect(); + /* istanbul ignore next */ + this.props.onClick(ev, { + axis: this.props.axis, + offset: + this.props.axis === AxisDirection.X + ? (ev.clientX || + (ev as unknown as TouchEvent).touches[0].clientX) - rect.left + : (ev.clientY || + (ev as unknown as TouchEvent).touches[0].clientY) - rect.top, + }); + } + } + + return true; + }; + + public render(): React.ReactElement | null { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + elementRef, + + axis, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onClick, + + ...props + } = this.props as ScrollbarTrackProps; + + props.className = cnb( + "ScrollbarsCustom-Track", + axis === AxisDirection.X + ? "ScrollbarsCustom-TrackX" + : "ScrollbarsCustom-TrackY", + props.className, + ); + + if (props.renderer) { + (props as ScrollbarTrackProps).axis = axis; + } + + return renderDivWithRenderer(props, this.elementRef); + } +} + +export default ScrollbarTrack; \ No newline at end of file diff --git a/packages/components/scrollbar/custom-scrollbar/index.ts b/packages/components/scrollbar/custom-scrollbar/index.ts new file mode 100644 index 0000000000..bad3753333 --- /dev/null +++ b/packages/components/scrollbar/custom-scrollbar/index.ts @@ -0,0 +1,7 @@ +import Scrollbar, { ScrollbarProps, ScrollbarState } from "./Scrollbar"; + +export { ScrollbarContext } from "./Scrollbar"; + +export { Scrollbar }; + +export type { ScrollbarProps, ScrollbarState }; \ No newline at end of file diff --git a/packages/components/scrollbar/custom-scrollbar/style.ts b/packages/components/scrollbar/custom-scrollbar/style.ts new file mode 100644 index 0000000000..843efa1fe1 --- /dev/null +++ b/packages/components/scrollbar/custom-scrollbar/style.ts @@ -0,0 +1,60 @@ +import * as React from "react"; + +export const style = { + holder: { + position: "relative", + width: "100%", + height: "100%", + } as React.CSSProperties, + + wrapper: { + position: "absolute", + top: 0, + left: 0, + bottom: 0, + right: 0, + } as React.CSSProperties, + + content: { + boxSizing: "border-box", + } as React.CSSProperties, + + track: { + common: { + position: "absolute", + overflow: "hidden", + borderRadius: 4, + background: "rgba(0,0,0,.1)", + userSelect: "none", + } as React.CSSProperties, + x: { + height: 10, + width: "calc(100% - 20px)", + bottom: 0, + left: 10, + } as React.CSSProperties, + y: { + width: 10, + height: "calc(100% - 20px)", + top: 10, + } as React.CSSProperties, + }, + + thumb: { + common: { + cursor: "pointer", + borderRadius: 4, + background: "rgba(0,0,0,.4)", + } as React.CSSProperties, + x: { + height: "100%", + width: 0, + } as React.CSSProperties, + y: { + width: "100%", + height: 0, + } as React.CSSProperties, + }, +}; + +export default style; \ No newline at end of file diff --git a/packages/components/scrollbar/custom-scrollbar/types.ts b/packages/components/scrollbar/custom-scrollbar/types.ts new file mode 100644 index 0000000000..8a451d3686 --- /dev/null +++ b/packages/components/scrollbar/custom-scrollbar/types.ts @@ -0,0 +1,106 @@ +import * as React from "react"; + +export enum AxisDirection { + X = "x", + Y = "y", +} + +export enum TrackClickBehavior { + JUMP = "jump", + STEP = "step", +} + +export type ElementRef = (element: T | null) => void; + +export type ElementPropsWithElementRef = + React.HTMLProps & { + elementRef?: ElementRef; + }; + +export type ElementRenderer = React.FC< + React.PropsWithChildren> +>; + +export type ElementPropsWithElementRefAndRenderer = + React.HTMLProps & { + elementRef?: ElementRef; + renderer?: ElementRenderer; + }; + +/** + * @description Contains all scroll-related values + */ +export type ScrollState = { + /** + * @description Scroller's native clientHeight parameter + */ + clientHeight: number; + /** + * @description Scroller's native clientWidth parameter + */ + clientWidth: number; + + /** + * @description Content's scroll height + */ + contentScrollHeight: number; + /** + * @description Content's scroll width + */ + contentScrollWidth: number; + + /** + * @description Scroller's native scrollHeight parameter + */ + scrollHeight: number; + /** + * @description Scroller's native scrollWidth parameter + */ + scrollWidth: number; + + /** + * @description Scroller's native scrollTop parameter + */ + scrollTop: number; + /** + * @description Scroller's native scrollLeft parameter + */ + scrollLeft: number; + + /** + * @description Indicates whether vertical scroll blocked via properties + */ + scrollYBlocked: boolean; + /** + * @description Indicates whether horizontal scroll blocked via properties + */ + scrollXBlocked: boolean; + + /** + * @description Indicates whether the content overflows vertically and scrolling not blocked + */ + scrollYPossible: boolean; + /** + * @description Indicates whether the content overflows horizontally and scrolling not blocked + */ + scrollXPossible: boolean; + + /** + * @description Indicates whether vertical track is visible + */ + trackYVisible: boolean; + /** + * @description Indicates whether horizontal track is visible + */ + trackXVisible: boolean; + + /** + * @description Indicates whether display direction is right-to-left + */ + isRTL?: boolean; + + /** + * @description Pages zoom level - it affects scrollbars + */ + zoomLevel: number; +}; \ No newline at end of file diff --git a/packages/components/scrollbar/custom-scrollbar/util.tsx b/packages/components/scrollbar/custom-scrollbar/util.tsx new file mode 100644 index 0000000000..f46916aadd --- /dev/null +++ b/packages/components/scrollbar/custom-scrollbar/util.tsx @@ -0,0 +1,298 @@ +import * as React from "react"; +import { ElementPropsWithElementRefAndRenderer, ElementRef } from "./types"; + +let doc: Document | null = typeof document === "object" ? document : null; + +export const isBrowser = + typeof window !== "undefined" && + typeof navigator !== "undefined" && + typeof document !== "undefined"; + +export const isUndef = (v: unknown): v is Exclude => { + return typeof v === "undefined"; +}; + +export const isFun = (v: unknown): v is CallableFunction => { + return typeof v === "function"; +}; + +export const isNum = (v: unknown): v is number => { + return typeof v === "number"; +}; + +/** + * @description Will return renderer result if presented, div element otherwise. + * If renderer is presented it'll receive `elementRef` function which should be used as HTMLElement's ref. + * + * @param props {ElementPropsWithElementRefAndRenderer} + * @param elementRef {ElementRef} + */ +export const renderDivWithRenderer = ( + props: ElementPropsWithElementRefAndRenderer, + elementRef: ElementRef, +): React.ReactElement | null => { + if (isFun(props.renderer)) { + props.elementRef = elementRef; + + const { renderer } = props; + + delete props.renderer; + + return renderer(props); + } + + delete props.elementRef; + + return
; +}; + +const getInnerSize = ( + el: HTMLElement, + dimension: string, + padding1: string, + padding2: string, +): number => { + const styles = getComputedStyle(el); + + if (styles.boxSizing === "border-box") { + return Math.max( + 0, + (Number.parseFloat(styles[dimension] as string) || 0) - + (Number.parseFloat(styles[padding1] as string) || 0) - + (Number.parseFloat(styles[padding2] as string) || 0), + ); + } + + return Number.parseFloat(styles[dimension] as string) || 0; +}; + +/** + * @description Return element's height without padding + */ +export const getInnerHeight = (el: HTMLElement): number => { + return getInnerSize(el, "height", "paddingTop", "paddingBottom"); +}; + +/** + * @description Return element's width without padding + */ +export const getInnerWidth = (el: HTMLElement): number => { + return getInnerSize(el, "width", "paddingLeft", "paddingRight"); +}; + +/** + * @description Return unique UUID v4 + */ +export const uuid = () => { + // eslint-disable-next-line @typescript-eslint/no-shadow + let uuid = ""; + + for (let i = 0; i < 32; i++) { + switch (i) { + case 8: + case 20: { + uuid += `-${Math.trunc(Math.random() * 16).toString(16)}`; + + break; + } + case 12: { + uuid += "-4"; + + break; + } + case 16: { + uuid += `-${((Math.random() * 16) | (0 & 3) | 8).toString(16)}`; + + break; + } + default: { + uuid += Math.trunc(Math.random() * 16).toString(16); + } + } + } + + return uuid; +}; + +/** + * @description Calculate thumb size for given viewport and track parameters + * + * @param {number} contentSize - Scrollable content size + * @param {number} viewportSize - Viewport size + * @param {number} trackSize - Track size thumb can move + * @param {number} minimalSize - Minimal thumb's size + * @param {number} maximalSize - Maximal thumb's size + */ +export const calcThumbSize = ( + contentSize: number, + viewportSize: number, + trackSize: number, + minimalSize?: number, + maximalSize?: number, +): number => { + if (viewportSize >= contentSize) { + return 0; + } + + let thumbSize = (viewportSize / contentSize) * trackSize; + + if (isNum(maximalSize)) { + thumbSize = Math.min(maximalSize, thumbSize); + } + if (isNum(minimalSize)) { + thumbSize = Math.max(minimalSize, thumbSize); + } + + return thumbSize; +}; + +/** + * @description Calculate thumb offset for given viewport, track and thumb parameters + * + * @param {number} contentSize - Scrollable content size + * @param {number} viewportSize - Viewport size + * @param {number} trackSize - Track size thumb can move + * @param {number} thumbSize - Thumb size + * @param {number} scroll - Scroll value to represent + */ +export const calcThumbOffset = ( + contentSize: number, + viewportSize: number, + trackSize: number, + thumbSize: number, + scroll: number, +): number => { + if (!scroll || !thumbSize || viewportSize >= contentSize) { + return 0; + } + + return ((trackSize - thumbSize) * scroll) / (contentSize - viewportSize); +}; + +/** + * @description Calculate scroll for given viewport, track and thumb parameters + * + * @param {number} contentSize - Scrollable content size + * @param {number} viewportSize - Viewport size + * @param {number} trackSize - Track size thumb can move + * @param {number} thumbSize - Thumb size + * @param {number} thumbOffset - Thumb's offset representing the scroll + */ +export const calcScrollForThumbOffset = ( + contentSize: number, + viewportSize: number, + trackSize: number, + thumbSize: number, + thumbOffset: number, +): number => { + if (!thumbOffset || !thumbSize || viewportSize >= contentSize) { + return 0; + } + + return (thumbOffset * (contentSize - viewportSize)) / (trackSize - thumbSize); +}; + +/** + * @description Set the document node to calculate the scrollbar width.
+ * null will force getter to return 0 (it'll imitate SSR). + */ +export const _dbgSetDocument = (v: Document | null): Document | null => { + if (v === null || v instanceof HTMLDocument) { + doc = v; + return doc; + } + + throw new TypeError( + `override value expected to be an instance of HTMLDocument or null, got ${typeof v}`, + ); +}; + +/** + * @description Return current document node + */ +export const _dbgGetDocument = (): Document | null => doc; + +interface GetScrollbarWidthFN { + _cache?: number; + + (force?: boolean): number | undefined; +} + +/** + * @description Returns scrollbar width specific for current environment. Can return undefined if DOM is not ready yet. + */ +export const getScrollbarWidth: GetScrollbarWidthFN = ( + force = false, +): number | undefined => { + if (!doc) { + getScrollbarWidth._cache = 0; + + return getScrollbarWidth._cache; + } + + if (!force && !isUndef(getScrollbarWidth._cache)) { + return getScrollbarWidth._cache as number; + } + + const el = doc.createElement("div"); + el.setAttribute( + "style", + "position:absolute;width:100px;height:100px;top:-999px;left:-999px;overflow:scroll;", + ); + + doc.body.append(el); + + /* istanbul ignore next */ + if (el.clientWidth === 0) { + // Do not even cache this value because there is no calculations. Issue https://github.com/xobotyi/react-scrollbars-custom/issues/123 + el.remove(); + return; + } + getScrollbarWidth._cache = 100 - el.clientWidth; + el.remove(); + + return getScrollbarWidth._cache; +}; + +interface ShouldReverseRtlScroll { + _cache?: boolean; + + (force?: boolean): boolean; +} + +/** + * @description Detect need of horizontal scroll reverse while RTL. + */ +export const shouldReverseRtlScroll: ShouldReverseRtlScroll = ( + force = false, +): boolean => { + if (!force && !isUndef(shouldReverseRtlScroll._cache)) { + return shouldReverseRtlScroll._cache as boolean; + } + + if (!doc) { + shouldReverseRtlScroll._cache = false; + + return shouldReverseRtlScroll._cache; + } + + const el = doc.createElement("div"); + const child = doc.createElement("div"); + + el.append(child); + + el.setAttribute( + "style", + "position:absolute;width:100px;height:100px;top:-999px;left:-999px;overflow:scroll;direction:rtl", + ); + child.setAttribute("style", "width:1000px;height:1000px"); + + doc.body.append(el); + + el.scrollLeft = -50; + shouldReverseRtlScroll._cache = el.scrollLeft === -50; + + el.remove(); + + return shouldReverseRtlScroll._cache; +}; \ No newline at end of file diff --git a/packages/components/scrollbar/styled-scrollbar.js b/packages/components/scrollbar/styled-scrollbar.js index df1422ef3d..6b0e38da86 100644 --- a/packages/components/scrollbar/styled-scrollbar.js +++ b/packages/components/scrollbar/styled-scrollbar.js @@ -1,4 +1,4 @@ -import Scrollbar from "react-scrollbars-custom"; +import { Scrollbar } from "./custom-scrollbar" import styled from "styled-components"; import Base from "../themes/base"; From 705cad387e0978c35c313c129dd80ef340a5186a Mon Sep 17 00:00:00 2001 From: Alexey Safronov Date: Mon, 19 Feb 2024 17:08:19 +0400 Subject: [PATCH 2/3] Bump version to v2.0.4 --- package.json | 2 +- packages/client/package.json | 2 +- packages/common/package.json | 2 +- packages/components/package.json | 2 +- packages/editor/package.json | 2 +- packages/login/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 117193e3c1..b3f86a28d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docspace", - "version": "2.0.3", + "version": "2.0.4", "private": true, "workspaces": { "packages": [ diff --git a/packages/client/package.json b/packages/client/package.json index caa13c711d..4da4bc42bf 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@docspace/client", - "version": "2.0.3", + "version": "2.0.4", "private": true, "homepage": "", "scripts": { diff --git a/packages/common/package.json b/packages/common/package.json index 9957ad3895..f444d68510 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@docspace/common", - "version": "2.0.3", + "version": "2.0.4", "private": true, "scripts": { "build": "echo 'skip it'", diff --git a/packages/components/package.json b/packages/components/package.json index 1cc7aab141..87d8e2bfcd 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@docspace/components", - "version": "2.0.3", + "version": "2.0.4", "private": true, "scripts": { "build": "echo 'skip it'", diff --git a/packages/editor/package.json b/packages/editor/package.json index ef18eed6a2..c408570c1c 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@docspace/editor", - "version": "2.0.3", + "version": "2.0.4", "private": true, "homepage": "/doceditor", "scripts": { diff --git a/packages/login/package.json b/packages/login/package.json index 5156cadc86..be734bfc3f 100644 --- a/packages/login/package.json +++ b/packages/login/package.json @@ -1,6 +1,6 @@ { "name": "@docspace/login", - "version": "2.0.3", + "version": "2.0.4", "private": true, "homepage": "/login", "scripts": { From 1c898a0083fcf559ae285b1a5f5424cd94ca17ff Mon Sep 17 00:00:00 2001 From: Timofey Boyko Date: Tue, 20 Feb 2024 12:15:00 +0300 Subject: [PATCH 3/3] Client: add component loader for fix failed upload chunk --- packages/client/src/routes/client.js | 37 ++-- packages/client/src/routes/confirm.js | 41 +++-- packages/client/src/routes/general.js | 6 +- packages/client/src/routes/portalSettings.js | 165 ++++++++++++------ packages/components/utils/component-loader.ts | 22 +++ 5 files changed, 191 insertions(+), 80 deletions(-) create mode 100644 packages/components/utils/component-loader.ts diff --git a/packages/client/src/routes/client.js b/packages/client/src/routes/client.js index e758382415..71241fdcaf 100644 --- a/packages/client/src/routes/client.js +++ b/packages/client/src/routes/client.js @@ -5,6 +5,7 @@ import loadable from "@loadable/component"; import PrivateRoute from "@docspace/common/components/PrivateRoute"; import PublicRoute from "@docspace/common/components/PublicRoute"; import ErrorBoundary from "@docspace/common/components/ErrorBoundary"; +import componentLoader from "@docspace/components/utils/component-loader"; import Error404 from "SRC_DIR/pages/Errors/404"; import FilesView from "SRC_DIR/pages/Home/View/Files"; @@ -13,24 +14,36 @@ import SettingsView from "SRC_DIR/pages/Home/View/Settings"; import { generalRoutes } from "./general"; -const Client = loadable(() => import("../Client")); +const Client = loadable(() => componentLoader(() => import("../Client"))); -const Home = loadable(() => import("../pages/Home")); +const Home = loadable(() => componentLoader(() => import("../pages/Home"))); -const Sdk = loadable(() => import("../pages/Sdk")); +const Sdk = loadable(() => componentLoader(() => import("../pages/Sdk"))); -const FormGallery = loadable(() => import("../pages/FormGallery")); -const PublicRoom = loadable(() => import("../pages/PublicRoom")); -const About = loadable(() => import("../pages/About")); -const Wizard = loadable(() => import("../pages/Wizard")); -const PreparationPortal = loadable(() => import("../pages/PreparationPortal")); -const PortalUnavailable = loadable(() => import("../pages/PortalUnavailable")); -const ErrorUnavailable = loadable(() => import("../pages/Errors/Unavailable")); +const FormGallery = loadable(() => + componentLoader(() => import("../pages/FormGallery")) +); +const PublicRoom = loadable(() => + componentLoader(() => import("../pages/PublicRoom")) +); +const About = loadable(() => componentLoader(() => import("../pages/About"))); +const Wizard = loadable(() => componentLoader(() => import("../pages/Wizard"))); +const PreparationPortal = loadable(() => + componentLoader(() => import("../pages/PreparationPortal")) +); +const PortalUnavailable = loadable(() => + componentLoader(() => import("../pages/PortalUnavailable")) +); +const ErrorUnavailable = loadable(() => + componentLoader(() => import("../pages/Errors/Unavailable")) +); const AccessRestricted = loadable(() => - import("../pages/Errors/AccessRestricted") + componentLoader(() => import("../pages/Errors/AccessRestricted")) ); -const Error401 = loadable(() => import("client/Error401")); +const Error401 = loadable(() => + componentLoader(() => import("client/Error401")) +); const ClientRoutes = [ { diff --git a/packages/client/src/routes/confirm.js b/packages/client/src/routes/confirm.js index 76737071a3..de0602cc15 100644 --- a/packages/client/src/routes/confirm.js +++ b/packages/client/src/routes/confirm.js @@ -3,52 +3,63 @@ import loadable from "@loadable/component"; import ConfirmRoute from "../helpers/confirmRoute"; import ErrorBoundary from "@docspace/common/components/ErrorBoundary"; +import componentLoader from "@docspace/components/utils/component-loader"; import Error404 from "SRC_DIR/pages/Errors/404"; import { AuthenticatedAction } from "../helpers/constants"; -const Confirm = loadable(() => import("../pages/Confirm")); +const Confirm = loadable(() => + componentLoader(() => import("../pages/Confirm")) +); const ActivateUserForm = loadable(() => - import("../pages/Confirm/sub-components/activateUser") + componentLoader(() => import("../pages/Confirm/sub-components/activateUser")) ); const CreateUserForm = loadable(() => - import("../pages/Confirm/sub-components/createUser") + componentLoader(() => import("../pages/Confirm/sub-components/createUser")) ); const ChangePasswordForm = loadable(() => - import("../pages/Confirm/sub-components/changePassword") + componentLoader(() => + import("../pages/Confirm/sub-components/changePassword") + ) ); const ActivateEmailForm = loadable(() => - import("../pages/Confirm/sub-components/activateEmail") + componentLoader(() => import("../pages/Confirm/sub-components/activateEmail")) ); const ChangeEmailForm = loadable(() => - import("../pages/Confirm/sub-components/changeEmail") + componentLoader(() => import("../pages/Confirm/sub-components/changeEmail")) ); const ChangePhoneForm = loadable(() => - import("../pages/Confirm/sub-components/changePhone") + componentLoader(() => import("../pages/Confirm/sub-components/changePhone")) ); const ProfileRemoveForm = loadable(() => - import("../pages/Confirm/sub-components/profileRemove") + componentLoader(() => import("../pages/Confirm/sub-components/profileRemove")) ); const ChangeOwnerForm = loadable(() => - import("../pages/Confirm/sub-components/changeOwner") + componentLoader(() => import("../pages/Confirm/sub-components/changeOwner")) ); const TfaAuthForm = loadable(() => - import("../pages/Confirm/sub-components/tfaAuth") + componentLoader(() => import("../pages/Confirm/sub-components/tfaAuth")) ); const TfaActivationForm = loadable(() => - import("../pages/Confirm/sub-components/tfaActivation") + componentLoader(() => import("../pages/Confirm/sub-components/tfaActivation")) ); const RemovePortal = loadable(() => - import("../pages/Confirm/sub-components/removePortal") + componentLoader(() => import("../pages/Confirm/sub-components/removePortal")) ); const DeactivatePortal = loadable(() => - import("../pages/Confirm/sub-components/deactivatePortal") + componentLoader(() => + import("../pages/Confirm/sub-components/deactivatePortal") + ) ); const ContinuePortal = loadable(() => - import("../pages/Confirm/sub-components/continuePortal") + componentLoader(() => + import("../pages/Confirm/sub-components/continuePortal") + ) +); +const Auth = loadable(() => + componentLoader(() => import("../pages/Confirm/sub-components/auth")) ); -const Auth = loadable(() => import("../pages/Confirm/sub-components/auth")); const confirmRoutes = [ { diff --git a/packages/client/src/routes/general.js b/packages/client/src/routes/general.js index 55008ca8f5..58f477a317 100644 --- a/packages/client/src/routes/general.js +++ b/packages/client/src/routes/general.js @@ -1,11 +1,13 @@ import React from "react"; import { Navigate } from "react-router-dom"; - import loadable from "@loadable/component"; import PrivateRoute from "@docspace/common/components/PrivateRoute"; +import componentLoader from "@docspace/components/utils/component-loader"; -const Profile = loadable(() => import("../pages/Profile")); +const Profile = loadable(() => + componentLoader(() => import("../pages/Profile")) +); const generalRoutes = [ { diff --git a/packages/client/src/routes/portalSettings.js b/packages/client/src/routes/portalSettings.js index 1ce45bad8e..16617f401d 100644 --- a/packages/client/src/routes/portalSettings.js +++ b/packages/client/src/routes/portalSettings.js @@ -4,136 +4,199 @@ import loadable from "@loadable/component"; import PrivateRoute from "@docspace/common/components/PrivateRoute"; import ErrorBoundary from "@docspace/common/components/ErrorBoundary"; +import componentLoader from "@docspace/components/utils/component-loader"; import Error404 from "SRC_DIR/pages/Errors/404"; import { generalRoutes } from "./general"; -const PortalSettings = loadable(() => import("../pages/PortalSettings")); +const PortalSettings = loadable(() => + componentLoader(() => import("../pages/PortalSettings")) +); const CustomizationSettings = loadable(() => - import("../pages/PortalSettings/categories/common/index.js") + componentLoader(() => + import("../pages/PortalSettings/categories/common/index.js") + ) ); const LanguageAndTimeZoneSettings = loadable(() => - import( - "../pages/PortalSettings/categories/common/Customization/language-and-time-zone" + componentLoader(() => + import( + "../pages/PortalSettings/categories/common/Customization/language-and-time-zone" + ) ) ); const WelcomePageSettings = loadable(() => - import( - "../pages/PortalSettings/categories/common/Customization/welcome-page-settings" + componentLoader(() => + import( + "../pages/PortalSettings/categories/common/Customization/welcome-page-settings" + ) ) ); const DNSSettings = loadable(() => - import("../pages/PortalSettings/categories/common/Customization/dns-settings") + componentLoader(() => + import( + "../pages/PortalSettings/categories/common/Customization/dns-settings" + ) + ) ); const PortalRenaming = loadable(() => - import( - "../pages/PortalSettings/categories/common/Customization/portal-renaming" + componentLoader(() => + import( + "../pages/PortalSettings/categories/common/Customization/portal-renaming" + ) ) ); const WhiteLabel = loadable(() => - import("../pages/PortalSettings/categories/common/Branding/whitelabel") + componentLoader(() => + import("../pages/PortalSettings/categories/common/Branding/whitelabel") + ) ); const CompanyInfoSettings = loadable(() => - import( - "../pages/PortalSettings/categories/common/Branding/companyInfoSettings" + componentLoader(() => + import( + "../pages/PortalSettings/categories/common/Branding/companyInfoSettings" + ) ) ); const AdditionalResources = loadable(() => - import( - "../pages/PortalSettings/categories/common/Branding/additionalResources" + componentLoader(() => + import( + "../pages/PortalSettings/categories/common/Branding/additionalResources" + ) ) ); const SecuritySettings = loadable(() => - import("../pages/PortalSettings/categories/security/index.js") + componentLoader(() => + import("../pages/PortalSettings/categories/security/index.js") + ) ); const TfaPage = loadable(() => - import("../pages/PortalSettings/categories/security/access-portal/tfa") + componentLoader(() => + import("../pages/PortalSettings/categories/security/access-portal/tfa") + ) ); const PasswordStrengthPage = loadable(() => - import( - "../pages/PortalSettings/categories/security/access-portal/passwordStrength" + componentLoader(() => + import( + "../pages/PortalSettings/categories/security/access-portal/passwordStrength" + ) ) ); const TrustedMailPage = loadable(() => - import( - "../pages/PortalSettings/categories/security/access-portal/trustedMail" + componentLoader(() => + import( + "../pages/PortalSettings/categories/security/access-portal/trustedMail" + ) ) ); const IpSecurityPage = loadable(() => - import("../pages/PortalSettings/categories/security/access-portal/ipSecurity") + componentLoader(() => + import( + "../pages/PortalSettings/categories/security/access-portal/ipSecurity" + ) + ) ); const BruteForceProtectionPage = loadable(() => - import( - "../pages/PortalSettings/categories/security/access-portal/bruteForceProtection" + componentLoader(() => + import( + "../pages/PortalSettings/categories/security/access-portal/bruteForceProtection" + ) ) ); const AdminMessagePage = loadable(() => - import( - "../pages/PortalSettings/categories/security/access-portal/adminMessage" + componentLoader(() => + import( + "../pages/PortalSettings/categories/security/access-portal/adminMessage" + ) ) ); const SessionLifetimePage = loadable(() => - import( - "../pages/PortalSettings/categories/security/access-portal/sessionLifetime" + componentLoader(() => + import( + "../pages/PortalSettings/categories/security/access-portal/sessionLifetime" + ) ) ); const Integration = loadable(() => - import("../pages/PortalSettings/categories/integration") + componentLoader(() => + import("../pages/PortalSettings/categories/integration") + ) ); const Payments = loadable(() => - import("../pages/PortalSettings/categories/payments") + componentLoader(() => import("../pages/PortalSettings/categories/payments")) ); const ThirdParty = loadable(() => - import( - "../pages/PortalSettings/categories/integration/ThirdPartyServicesSettings" + componentLoader(() => + import( + "../pages/PortalSettings/categories/integration/ThirdPartyServicesSettings" + ) ) ); const DocumentService = loadable(() => - import("../pages/PortalSettings/categories/integration/DocumentService") + componentLoader(() => + import("../pages/PortalSettings/categories/integration/DocumentService") + ) ); const SingleSignOn = loadable(() => - import("../pages/PortalSettings/categories/integration/SingleSignOn") + componentLoader(() => + import("../pages/PortalSettings/categories/integration/SingleSignOn") + ) ); const SPSettings = loadable(() => - import( - "../pages/PortalSettings/categories/integration/SingleSignOn/SPSettings" + componentLoader(() => + import( + "../pages/PortalSettings/categories/integration/SingleSignOn/SPSettings" + ) ) ); const SPMetadata = loadable(() => - import( - "../pages/PortalSettings/categories/integration/SingleSignOn/ProviderMetadata" + componentLoader(() => + import( + "../pages/PortalSettings/categories/integration/SingleSignOn/ProviderMetadata" + ) ) ); const DeveloperTools = loadable(() => - import("../pages/PortalSettings/categories/developer-tools/index.js") + componentLoader(() => + import("../pages/PortalSettings/categories/developer-tools/index.js") + ) ); const WebhookHistory = loadable(() => - import( - "../pages/PortalSettings/categories/developer-tools/Webhooks/WebhookHistory" + componentLoader(() => + import( + "../pages/PortalSettings/categories/developer-tools/Webhooks/WebhookHistory" + ) ) ); const WebhookDetails = loadable(() => - import( - "../pages/PortalSettings/categories/developer-tools/Webhooks/WebhookEventDetails" + componentLoader(() => + import( + "../pages/PortalSettings/categories/developer-tools/Webhooks/WebhookEventDetails" + ) ) ); const Backup = loadable(() => - import("../pages/PortalSettings/categories/data-management/index") -); -const DeleteDataPage = loadable(() => - import("../pages/PortalSettings/categories/delete-data") -); -const RestoreBackup = loadable(() => - import( - "../pages/PortalSettings/categories/data-management/backup/restore-backup/index" + componentLoader(() => + import("../pages/PortalSettings/categories/data-management/index") ) ); -const Bonus = loadable(() => import("../pages/Bonus")); +const DeleteDataPage = loadable(() => + componentLoader(() => + import("../pages/PortalSettings/categories/delete-data") + ) +); +const RestoreBackup = loadable(() => + componentLoader(() => + import( + "../pages/PortalSettings/categories/data-management/backup/restore-backup/index" + ) + ) +); +const Bonus = loadable(() => componentLoader(() => import("../pages/Bonus"))); const PortalSettingsRoutes = { path: "portal-settings/", diff --git a/packages/components/utils/component-loader.ts b/packages/components/utils/component-loader.ts new file mode 100644 index 0000000000..4f3f2d3b2f --- /dev/null +++ b/packages/components/utils/component-loader.ts @@ -0,0 +1,22 @@ +export default function componentLoader( + lazyComponent: Function, + attemptsLeft: number = 3 +) { + return new Promise((resolve, reject) => { + lazyComponent() + .then(resolve) + .catch((error: unknown) => { + // let us retry after 1500 ms + setTimeout(() => { + if (attemptsLeft === 1) { + reject(error); + return; + } + componentLoader(lazyComponent, attemptsLeft - 1).then( + resolve, + reject + ); + }, 1500); + }); + }); +}