import cls from 'classnames'; import React, { MouseEvent, UIEvent } from 'react'; import styles from './index.module.less'; export interface ScrollAreaProps { className?: string; onScroll?: (position: ScrollPosition) => any; atTopClassName?: string; style?: any; containerStyle?: any; delegate?: (delegate: IScrollDelegate) => void; forwardedRef: (ref: HTMLDivElement) => void; children: any; } export interface IScrollDelegate { scrollTo(position: ScrollPosition): void; } export interface ScrollPosition { top: number; left: number; } export interface ScrollSizes { scrollHeight: number; offsetHeight: number; offsetWidth: number; scrollWidth: number; } export class Scroll extends React.Component { public ref: HTMLDivElement; public container: HTMLDivElement; private thumbV!: HTMLDivElement; private trackV!: HTMLDivElement; private thumbH!: HTMLDivElement; private trackH!: HTMLDivElement; private decorationL: HTMLDivElement; private decorationR: HTMLDivElement; private size: ScrollSizes; private position: ScrollPosition = { top: 0, left: 0, }; private dragging = false; private draggingStart = 0; private draggingStartPos = 0; private requestFrame: any; private shouldHideThumb = true; private isAtTop = true; onScroll(e: UIEvent) { this.position = { top: this.ref.scrollTop, left: this.ref.scrollLeft, }; if (this.props.onScroll) { this.props.onScroll(this.position); } this.update(() => { const contentWidth = this.ref.scrollWidth; const width = this.ref.offsetWidth; const contentHeight = this.ref.scrollHeight; const height = this.ref.offsetHeight; this.thumbH.style.left = (this.position.left * width) / contentWidth + 'px'; this.thumbV.style.top = (this.position.top * height) / contentHeight + 'px'; }); if (!this.isAtTop && this.ref.scrollTop === 0) { this.isAtTop = true; this.setCss(); } else if (this.isAtTop && this.ref.scrollTop !== 0) { this.isAtTop = false; this.setCss(); } if (this.ref.scrollLeft > 0) { this.decorationL.style.opacity = String(1); this.decorationR.style.opacity = String(1); } else { this.decorationL.style.opacity = String(0); } if ( this.ref.scrollWidth === this.ref.offsetWidth || this.ref.scrollLeft === this.ref.scrollWidth - this.ref.offsetWidth ) { this.decorationR.style.opacity = String(0); } } scrollTo(position: ScrollPosition) { this.ref.scrollLeft = position.left; this.ref.scrollTop = position.top; } onMouseDownHorizontal(e: MouseEvent) { this.dragging = true; if (e.target === this.trackH) { this.onMouseDownOnTrack(e); } this.draggingStart = e.pageX; this.draggingStartPos = this.ref.scrollLeft; document.addEventListener('mousemove', this.onMouseMoveHorizontal); document.addEventListener('mouseup', this.onMouseUpHorizontal); } onMouseMoveHorizontal = (e) => { if (!this.dragging) { return; } const move = e.pageX - this.draggingStart; this.ref.scrollLeft = this.draggingStartPos + this.calculateXToLeft(move); }; onMouseUpHorizontal = (e) => { this.dragging = false; document.removeEventListener('mousemove', this.onMouseMoveHorizontal); document.removeEventListener('mouseup', this.onMouseUpHorizontal); if (this.shouldHideThumb) { this.hideThumb(); } }; onMouseDownOnTrack(e: MouseEvent) { const track = e.target as HTMLDivElement; const x = e.clientX - track.getBoundingClientRect().left; const contentWidth = this.ref.scrollWidth; const width = this.ref.offsetWidth; const left = (x * contentWidth) / width - 0.5 * width; this.scrollTo({ left, top: this.position.top, }); } calculateXToLeft(x) { const contentWidth = this.ref.scrollWidth; const width = this.ref.offsetWidth; return (x * contentWidth) / width; } onMouseDownVertical(e: MouseEvent) { this.dragging = true; if (e.target === this.trackV) { this.onMouseDownOnTrackVertical(e); } this.draggingStart = e.pageY; this.draggingStartPos = this.ref.scrollTop; document.addEventListener('mousemove', this.onMouseMoveVertical); document.addEventListener('mouseup', this.onMouseUpVertical); } onMouseMoveVertical = (e) => { if (!this.dragging) { return; } const move = e.pageY - this.draggingStart; this.ref.scrollTop = this.draggingStartPos + this.calculateYToTop(move); }; onMouseUpVertical = (e) => { this.dragging = false; document.removeEventListener('mousemove', this.onMouseMoveVertical); document.removeEventListener('mouseup', this.onMouseUpVertical); if (this.shouldHideThumb) { this.hideThumb(); } }; onMouseDownOnTrackVertical(e: MouseEvent) { const track = e.target as HTMLDivElement; const x = e.clientY - track.getBoundingClientRect().top; const contentHeight = this.ref.scrollHeight; const height = this.ref.offsetHeight; const top = (x * contentHeight) / height - 0.5 * height; this.scrollTo({ left: this.position.left, top, }); } onMousewheel = (e: WheelEvent) => { // 鼠标滚动滚轮只在有横向滚动条的情况下 // 页面有缩放的时候,scrollHeight 可能会小于 clientHeight / offsetHeight if (this.ref.clientHeight >= this.ref.scrollHeight) { if (e.deltaY !== 0) { // scrollLeft 内部有边界判断 this.ref.scrollLeft += e.deltaY; } if (e.deltaX !== 0) { this.ref.scrollLeft += e.deltaX; } } }; calculateYToTop(y) { const contentHeight = this.ref.scrollHeight; const height = this.ref.offsetHeight; return (y * contentHeight) / height; } componentDidUpdate() { this.update(); if (this.props.delegate) { this.props.delegate({ scrollTo: this.scrollTo.bind(this), }); } } componentDidMount() { this.update(); window.addEventListener('resize', this.handleWindowResize); if (this.props.delegate) { this.props.delegate({ scrollTo: this.scrollTo.bind(this), }); } if (this.ref) { this.ref.addEventListener('mouseenter', this.onMouseEnter); this.ref.addEventListener('wheel', this.onMousewheel); } } onMouseEnter = () => { this.update(); }; componentWillUnmount() { if (this.ref) { this.ref.removeEventListener('mouseenter', this.onMouseEnter); this.ref.removeEventListener('wheel', this.onMousewheel); } window.removeEventListener('resize', this.handleWindowResize); if (this.requestFrame) { window.cancelAnimationFrame(this.requestFrame); } } handleWindowResize = () => { this.update(); }; sizeEqual(size1: ScrollSizes, size2: ScrollSizes): boolean { return ( size1 && size2 && size1.offsetHeight === size2.offsetHeight && size1.scrollHeight === size2.scrollHeight && size1.offsetWidth === size2.offsetWidth && size1.scrollWidth === size2.scrollWidth ); } update = (callback?) => { if (this.requestFrame) { window.cancelAnimationFrame(this.requestFrame); } this.requestFrame = window.requestAnimationFrame(() => { this._update(); if (callback) { callback(); } }); }; _update() { if (this.ref) { if (!this.sizeEqual(this.size, this.ref)) { this.size = { offsetHeight: this.ref.offsetHeight, offsetWidth: this.ref.offsetWidth, scrollWidth: this.ref.scrollWidth, scrollHeight: this.ref.scrollHeight, }; this.updateScrollBar(); } } } updateScrollBar() { const contentWidth = this.ref.scrollWidth; const width = this.ref.offsetWidth; if (width < contentWidth) { const thumbHWidth = (width * width) / contentWidth; this.thumbH.style.width = thumbHWidth + 'px'; this.trackH.parentElement!.style.display = 'block'; } else { this.trackH.parentElement!.style.display = 'none'; } const contentHeight = this.ref.scrollHeight; const height = this.ref.offsetHeight; if (height < contentHeight) { this.thumbV.style.height = (height * height) / contentHeight + 'px'; this.trackV.parentElement!.style.display = 'block'; } else { this.trackV.parentElement!.style.display = 'none'; } } hideThumb() { this.shouldHideThumb = true; if (!this.dragging) { this.setCss(); } } showThumb() { this.shouldHideThumb = false; this.setCss(); } setCss() { this.container.className = cls({ [styles.scroll]: true, [styles['hide-thumb']]: this.shouldHideThumb && !this.dragging, }); const clses = {}; if (this.props.atTopClassName) { clses[this.props.atTopClassName] = this.isAtTop; } if (this.props.className) { clses[this.props.className] = true; } this.ref.className = cls(clses); } render() { return (
{ if (e) { this.container = e; this.props.forwardedRef(e); } }} onMouseMove={() => this.showThumb()} onMouseLeave={() => this.hideThumb()} style={this.props.containerStyle} >
e && (this.ref = e)} onMouseDown={() => this.update()} onMouseUp={() => this.update()} > {this.props.children}
e && (this.decorationL = e)} />
e && (this.decorationR = e)} />
e && (this.trackH = e)} onMouseDown={this.onMouseDownHorizontal.bind(this)} />
e && (this.thumbH = e)} />
e && (this.trackV = e)} onMouseDown={this.onMouseDownVertical.bind(this)} />
e && (this.thumbV = e)} />
); } }