import * as React from 'react'; import styled from 'styled-components'; import { MacOsScrollbarVisibility } from '../../shared/ipc-schema'; import { Scheduler } from '../../shared/scheduler'; import { useSelector } from '../redux/store'; const StyledScrollableContent = styled.div({ display: 'flex', flexDirection: 'column', minHeight: '100%', height: 'max-content', }); const StyledCustomScrollbars = styled.div({ display: 'flex', flexDirection: 'column', position: 'relative', overflow: 'hidden', }); const StyledScrollable = styled.div<{ $fillContainer?: boolean }>((props) => ({ flex: props.$fillContainer ? '1' : undefined, width: '100%', overflow: 'auto', '&&::-webkit-scrollbar': { display: 'none', }, })); const StyledTrack = styled.div<{ $canScroll: boolean; $show: boolean }>((props) => ({ position: 'absolute', top: 0, right: 0, bottom: 0, width: '16px', backgroundColor: props.$show ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0)', borderRadius: '8px', transition: 'width 0.1s ease-in-out, background-color 0.25s ease-in-out', zIndex: 99, pointerEvents: props.$canScroll ? 'auto' : 'none', })); const StyledThumb = styled.div<{ $show: boolean; $isDragging: boolean; $wide: boolean }>( (props) => ({ position: 'absolute', top: 0, right: 0, borderRadius: props.$wide ? '6px' : '4px', width: props.$wide ? '12px' : '8px', transition: 'width 0.25s ease-in-out, border-radius 0.25s ease-in-out, height 0.25s ease-in-out, opacity 0.25s ease-in-out, background-color 0.1s ease-in-out', opacity: props.$show ? 1 : 0, backgroundColor: props.$isDragging ? 'rgba(255, 255, 255, 0.65)' : 'rgba(255, 255, 255, 0.4)', // Thumb should be less transparent when track is hovered. [`${StyledTrack}:hover &&`]: { backgroundColor: 'rgba(255, 255, 255, 0.65)', }, }), ); const AUTOHIDE_TIMEOUT = 1000; interface IProps { autoHide?: boolean; trackPadding?: { x: number; y: number }; onScroll?: (value: IScrollEvent) => void; className?: string; fillContainer?: boolean; children?: React.ReactNode; } interface IState { canScroll: boolean; showScrollIndicators: boolean; active: boolean; isDragging: boolean; dragStart: { x: number; y: number; }; } export interface IScrollEvent { scrollLeft: number; scrollTop: number; } export type ScrollPosition = 'top' | 'bottom' | 'middle'; interface IScrollbarUpdateContext { size: boolean; position: boolean; } export default React.forwardRef(function CustomScrollbarsContainer( props: IProps, forwardRef: React.Ref, ) { const macOsScrollbarVisibility = useSelector( (state) => state.userInterface.macOsScrollbarVisibility, ); const autoHide = props.autoHide ?? (window.env.platform === 'darwin' && (macOsScrollbarVisibility === undefined || macOsScrollbarVisibility === MacOsScrollbarVisibility.whenScrolling)); return ; }); export type CustomScrollbarsRef = CustomScrollbars; class CustomScrollbars extends React.Component { public static defaultProps: Partial = { trackPadding: { x: 2, y: 2 }, }; public state = { canScroll: false, showScrollIndicators: true, active: false, isDragging: false, dragStart: { x: 0, y: 0 }, }; private scrollableRef = React.createRef(); private scrollableContentRef = React.createRef(); private trackRef = React.createRef(); private thumbRef = React.createRef(); private autoHideScheduler = new Scheduler(); // Update scrollbar when content grows/shrinks. private contentResizeObserver = new ResizeObserver(() => { this.updateScrollbarsHelper({ size: true }); }); public scrollToTop(smooth = false) { const scrollable = this.scrollableRef.current; scrollable?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' }); } public scrollTo(x: number, y: number, smooth = false) { const scrollable = this.scrollableRef.current; scrollable?.scrollTo({ top: y, left: x, behavior: smooth ? 'smooth' : 'auto' }); } public scrollToElement(child: HTMLElement, scrollPosition: ScrollPosition) { const scrollable = this.scrollableRef.current; if (scrollable) { // throw if child is not a descendant of scroll view if (!scrollable.contains(child)) { throw new Error( 'Cannot scroll to an element which is not a descendant of CustomScrollbars.', ); } const scrollTop = this.computeScrollTop(scrollable, child, scrollPosition); this.scrollTo(0, scrollTop); } } public scrollIntoView(elementRect: DOMRect) { const scrollable = this.scrollableRef.current; if (scrollable) { const scrollableRect = scrollable.getBoundingClientRect(); // The element position needs to be relative to the parent, not the document const elementTop = elementRect.top - scrollableRect.top; const bottomOverflow = elementTop + elementRect.height - scrollableRect.height; let scrollDistance = 0; if (elementTop < 0) { scrollDistance = elementTop; } else if (bottomOverflow > 0) { // Prevent the elements top from being scrolled out of the visible area scrollDistance = Math.min(bottomOverflow, elementTop); } scrollable.scrollBy({ top: scrollDistance, behavior: 'smooth', }); } } public getScrollPosition(): [number, number] { const scroll = this.scrollableRef.current; if (scroll) { return [scroll.scrollLeft, scroll.scrollTop]; } else { return [0, 0]; } } public componentDidMount() { this.updateScrollbarsHelper({ position: true, size: true, }); document.addEventListener('mousemove', this.handleMouseMove); document.addEventListener('mouseup', this.handleMouseUp); // show scroll indicators briefly when mounted if (this.props.autoHide) { this.startAutoHide(); } if (this.scrollableContentRef.current) { this.contentResizeObserver.observe(this.scrollableContentRef.current); } } public shouldComponentUpdate(nextProps: IProps, nextState: IState) { const prevProps = this.props; const prevState = this.state; return ( prevProps.children !== nextProps.children || prevProps.autoHide !== nextProps.autoHide || prevProps.trackPadding?.x !== nextProps.trackPadding?.x || prevProps.trackPadding?.y !== nextProps.trackPadding?.y || prevState.canScroll !== nextState.canScroll || prevState.showScrollIndicators !== nextState.showScrollIndicators || prevState.isDragging !== nextState.isDragging || prevState.active !== nextState.active ); } public componentWillUnmount() { this.autoHideScheduler.cancel(); document.removeEventListener('mousemove', this.handleMouseMove); document.removeEventListener('mouseup', this.handleMouseUp); if (this.scrollableContentRef.current) { this.contentResizeObserver.unobserve(this.scrollableContentRef.current); } } public componentDidUpdate() { this.updateScrollbarsHelper({ position: true, size: true, }); } public render() { const { autoHide: _autoHide, trackPadding: _trackPadding, onScroll: _onScroll, fillContainer, children, ...otherProps } = this.props; const showScrollbars = this.state.canScroll && this.state.showScrollIndicators; return ( {children} ); } private onScroll = () => { this.updateScrollbarsHelper({ position: true }); if (this.props.autoHide) { this.ensureScrollbarsVisible(); // only auto-hide when scrolling with mousewheel if (!this.state.isDragging) { this.startAutoHide(); } } else { // only auto-shrink when scrolling with mousewheel if (!this.state.isDragging) { this.startAutoShrink(); } } const scrollView = this.scrollableRef.current; if (scrollView && this.props.onScroll) { this.props.onScroll({ scrollLeft: scrollView.scrollLeft, scrollTop: scrollView.scrollTop, }); } }; private handleMouseEnter = () => { this.autoHideScheduler.cancel(); this.setState({ showScrollIndicators: true, active: true, }); }; private handleMouseLeave = () => { // do not hide the scrollbar if user is dragging a thumb but left the track area. if (!this.state.isDragging) { this.mouseLeaveAction(); } }; private mouseLeaveAction = () => { if (this.props.autoHide) { this.startAutoHide(); } else { this.startAutoShrink(); } }; private handleMouseDown = (event: React.MouseEvent) => { // initiate dragging when user clicked inside of thumb const thumb = this.thumbRef.current; if (thumb === event.target || thumb?.contains(event.target as Node)) { const cursorPosition = { x: event.clientX, y: event.clientY, }; this.setState({ isDragging: true, dragStart: this.getPointRelativeToElement(thumb, cursorPosition), }); } }; private handleMouseUp = (event: MouseEvent) => { if (!this.state.isDragging) { return; } this.setState({ isDragging: false, }); const track = this.trackRef.current; if (track) { // Make sure to auto-hide the scrollbar if cursor ended up outside of scroll track const cursorPosition = { x: event.clientX, y: event.clientY, }; if (!this.isPointInsideOfElement(track, cursorPosition)) { this.mouseLeaveAction(); } } }; private handleMouseMove = (event: MouseEvent) => { const scrollable = this.scrollableRef.current; const thumb = this.thumbRef.current; const cursorPosition = { x: event.clientX, y: event.clientY, }; if (this.state.isDragging && scrollable && thumb) { // the content height of the scroll view const scrollHeight = scrollable.scrollHeight; // the visible height of the scroll view const visibleHeight = scrollable.offsetHeight; // lowest point of scrollTop const maxScrollTop = scrollHeight - visibleHeight; // Map absolute cursor coordinate to point in scroll container const pointInScrollContainer = this.getPointRelativeToElement(scrollable, cursorPosition); // calculate the thumb boundary to make sure that the visual appearance of // a thumb at the lowest point matches the bottom of scrollable view const thumbBoundary = this.computeTrackLength(scrollable) - thumb.clientHeight; const thumbTop = pointInScrollContainer.y - this.state.dragStart.y - (this.props.trackPadding?.y ?? 0); const newScrollTop = (thumbTop / thumbBoundary) * maxScrollTop; scrollable.scrollTop = newScrollTop; } }; private ensureScrollbarsVisible() { if (!this.state.showScrollIndicators) { this.setState({ showScrollIndicators: true, }); } } private startAutoHide() { this.autoHideScheduler.schedule(() => { this.setState({ showScrollIndicators: false, active: false, }); }, AUTOHIDE_TIMEOUT); } private startAutoShrink() { this.autoHideScheduler.schedule(() => { this.setState({ active: false, }); }, AUTOHIDE_TIMEOUT); } private isPointInsideOfElement(element: HTMLElement, point: { x: number; y: number }) { const rect = element.getBoundingClientRect(); return ( point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom ); } private getPointRelativeToElement(element: HTMLElement, point: { x: number; y: number }) { const rect = element.getBoundingClientRect(); return { x: point.x - rect.left, y: point.y - rect.top, }; } private computeTrackLength(scrollable: HTMLElement) { return scrollable.offsetHeight - (this.props.trackPadding?.y ?? 0) * 2; } // Computes the position of child element within scrollable container private computeOffsetTop(scrollable: HTMLElement, child: HTMLElement) { let offsetTop = 0; let node = child; while (scrollable.contains(node)) { offsetTop += node.offsetTop; if (node.offsetParent) { node = node.offsetParent as HTMLElement; } else { break; } } return offsetTop; } private computeScrollTop( scrollable: HTMLElement, child: HTMLElement, scrollPosition: ScrollPosition, ) { const offsetTop = this.computeOffsetTop(scrollable, child); switch (scrollPosition) { case 'top': return offsetTop; case 'bottom': return offsetTop - (scrollable.offsetHeight - child.clientHeight); case 'middle': return offsetTop - (scrollable.offsetHeight - child.clientHeight) * 0.5; } } private computeThumbPosition(scrollable: HTMLElement, thumb: HTMLElement) { // the content height of the scroll view const scrollHeight = scrollable.scrollHeight; // the visible height of the scroll view const visibleHeight = scrollable.offsetHeight; // scroll offset const scrollTop = scrollable.scrollTop; // lowest point of scrollTop const maxScrollTop = scrollHeight - visibleHeight; // calculate scroll position within 0..1 range const scrollPosition = scrollHeight > 0 ? scrollTop / maxScrollTop : 0; // calculate the thumb boundary to make sure that the visual appearance of // a thumb at the lowest point matches the bottom of scrollable view const thumbBoundary = this.computeTrackLength(scrollable) - thumb.clientHeight; // calculate thumb position based on scroll progress and thumb boundary // adding vertical inset to adjust the thumb's appearance const thumbPosition = thumbBoundary * scrollPosition + (this.props.trackPadding?.y ?? 0); return { x: -(this.props.trackPadding?.x ?? 0), y: thumbPosition, }; } private computeThumbHeight(scrollable: HTMLElement) { const scrollHeight = scrollable.scrollHeight; const visibleHeight = scrollable.offsetHeight; const thumbHeight = (visibleHeight / scrollHeight) * visibleHeight; // ensure that the scroll thumb doesn't shrink to nano size return Math.max(thumbHeight, 8); } private updateScrollbarsHelper(updateFlags: Partial) { const scrollable = this.scrollableRef.current; const thumb = this.thumbRef.current; if (scrollable && thumb) { this.updateScrollbars(scrollable, thumb, updateFlags); } } private updateScrollbars( scrollable: HTMLElement, thumb: HTMLElement, context: Partial, ) { if (context.size) { const thumbHeight = this.computeThumbHeight(scrollable); thumb.style.setProperty('height', thumbHeight + 'px'); // hide thumb when there is nothing to scroll. We've had issues with scrollHeight being // off-by-one, to ensure this doesn't happen we subtract 1 here. const canScroll = thumbHeight < scrollable.offsetHeight - 1; if (this.state.canScroll !== canScroll) { this.setState({ canScroll }); // flash the scroll indicators when the view becomes scrollable if (this.props.autoHide && canScroll) { this.startAutoHide(); this.ensureScrollbarsVisible(); } } } if (context.position) { const { x, y } = this.computeThumbPosition(scrollable, thumb); thumb.style.setProperty('transform', `translate(${x}px, ${y}px)`); } } }