react-scrollbar-possible
Version:
ScrollArea component for react
624 lines (542 loc) • 21.9 kB
JSX
import React from 'react';
import ScrollBar from './Scrollbar';
import {findDOMNode, warnAboutFunctionChild, warnAboutElementChild, positiveOrZero, modifyObjValues} from './utils';
import lineHeight from 'line-height';
import {Motion, spring} from 'react-motion';
const eventTypes = {
wheel: 'wheel',
api: 'api',
touch: 'touch',
touchEnd: 'touchEnd',
mousemove: 'mousemove',
keyPress: 'keypress'
};
export default class ScrollArea extends React.Component {
constructor(props) {
super(props);
this.state = {
topPosition: 0,
leftPosition: 0,
realHeight: 0,
containerHeight: 0,
realWidth: 0,
containerWidth: 0,
swapWheelAxes: false,
dragging: false,
dragX: 0,
dragY: 0
};
this.scrollArea = {
refresh: () => {
this.setSizesToState();
},
scrollTop: () => {
this.scrollTop();
},
scrollBottom: () => {
this.scrollBottom();
},
scrollYTo: (position) => {
this.scrollYTo(position);
},
scrollLeft: () => {
this.scrollLeft();
},
scrollRight: () => {
this.scrollRight();
},
scrollXTo: (position) => {
this.scrollXTo(position);
},
centerTo: (x, y) => {
this.centerTo(x, y);
},
getCenterX: () => {
this.getCenterX();
},
getCenterY: () => {
this.getCenterY();
}
};
this.evntsPreviousValues = {
clientX: 0,
clientY: 0,
deltaX: 0,
deltaY: 0
};
this.bindedHandleMouseUp = this.handleMouseUp.bind(this);
this.bindedHandleWindowResize = this.handleWindowResize.bind(this);
}
getChildContext() {
return {
scrollArea: this.scrollArea
};
}
componentDidMount() {
if (this.props.contentWindow) {
this.props.contentWindow.addEventListener("resize", this.bindedHandleWindowResize);
}
window.addEventListener('mouseup', this.bindedHandleMouseUp);
this.lineHeightPx = lineHeight(findDOMNode(this.content));
this.props.focusing ? this.setSizesScrollFromFocusToState() : this.setSizesToState();
}
componentWillUnmount() {
if (this.props.contentWindow) {
this.props.contentWindow.removeEventListener("resize", this.bindedHandleWindowResize);
}
window.removeEventListener('mouseup', this.bindedHandleMouseUp);
}
componentDidUpdate(prevProps) {
this.props.focusing ? this.setSizesScrollFromFocusToState(prevProps) : this.setSizesToState();
}
render() {
let {children, className, contentClassName, ownerDocument} = this.props;
let withMotion = this.props.smoothScrolling &&
(this.state.eventType === eventTypes.wheel || this.state.eventType === eventTypes.api || this.state.eventType === eventTypes.touchEnd ||
this.state.eventType === eventTypes.keyPress);
const shorten = (this.canScrollX() && this.canScrollY());
let scrollbarY = this.canScrollY() ? (
<ScrollBar
ownerDocument={ownerDocument}
realSize={this.state.realHeight - (shorten ? 12 : 0)}
containerSize={this.state.containerHeight - (shorten ? 12 : 0)}
position={this.state.topPosition}
onMove={this.handleScrollbarMove.bind(this)}
onPositionChange={this.handleScrollbarYPositionChange.bind(this)}
containerStyle={this.props.verticalContainerStyle}
scrollbarStyle={this.props.verticalScrollbarStyle}
smoothScrolling={withMotion}
minScrollSize={this.props.minScrollSize}
onFocus={this.focusContent.bind(this)}
type="vertical"
shorten={shorten}
/>
) : null;
let scrollbarX = this.canScrollX() ? (
<ScrollBar
ownerDocument={ownerDocument}
realSize={this.state.realWidth - (shorten ? 12 : 0)}
containerSize={this.state.containerWidth - (shorten ? 12 : 0)}
position={this.state.leftPosition}
onMove={this.handleScrollbarMove.bind(this)}
onPositionChange={this.handleScrollbarXPositionChange.bind(this)}
containerStyle={this.props.horizontalContainerStyle}
scrollbarStyle={this.props.horizontalScrollbarStyle}
smoothScrolling={withMotion}
minScrollSize={this.props.minScrollSize}
onFocus={this.focusContent.bind(this)}
type="horizontal"
shorten={shorten}
/>
) : null;
if (typeof children === 'function') {
warnAboutFunctionChild();
children = children();
} else {
warnAboutElementChild();
}
let classes = 'scrollarea ' + (className || '');
let contentClasses = 'scrollarea-content ' + (contentClassName || '');
let contentStyle = {
marginTop: -this.state.topPosition,
marginLeft: -this.state.leftPosition
};
let springifiedContentStyle = withMotion ? modifyObjValues(contentStyle, x => spring(x)) : contentStyle;
return (
<Motion style={springifiedContentStyle}>
{ style =>
<div
ref={x => this.wrapper = x}
className={classes}
style={this.props.style}
onWheel={this.handleWheel.bind(this)}
>
<div
ref={x => this.content = x}
style={{ ...this.props.contentStyle, ...style }}
className={contentClasses}
onTouchStart={this.handleTouchStart.bind(this)}
onTouchMove={this.handleTouchMove.bind(this)}
onTouchEnd={this.handleTouchEnd.bind(this)}
onKeyDown={this.handleKeyDown.bind(this)}
onKeyUp={this.handleKeyUp.bind(this)}
onMouseDown={this.handleMouseDown.bind(this)}
onMouseMove={this.handleMouseMove.bind(this)}
tabIndex={this.props.focusableTabIndex}
>
{children}
</div>
{scrollbarY}
{scrollbarX}
{shorten && <div style={{position: "absolute", width: 12, height: 12, right: 0, bottom: 0, background: "#AAC0FF", zIndex: 19999}} />}
</div>
}
</Motion>
);
}
setStateFromEvent(newState, eventType) {
if (this.props.onScroll) {
this.props.onScroll(newState);
}
this.setState({...newState, eventType});
}
handleTouchStart(e) {
let {touches} = e;
if (touches.length === 1) {
let {clientX, clientY} = touches[0];
this.eventPreviousValues = {
...this.eventPreviousValues,
clientY,
clientX,
timestamp: Date.now()
};
}
}
handleTouchMove(e) {
e.preventDefault();
e.stopPropagation();
let {touches} = e;
if (touches.length === 1) {
let {clientX, clientY} = touches[0];
let deltaY = this.eventPreviousValues.clientY - clientY;
let deltaX = this.eventPreviousValues.clientX - clientX;
this.eventPreviousValues = {
...this.eventPreviousValues,
deltaY,
deltaX,
clientY,
clientX,
timestamp: Date.now()
};
this.setStateFromEvent(this.composeNewState(-deltaX, -deltaY));
}
}
handleTouchEnd(e) {
let {deltaX, deltaY, timestamp} = this.eventPreviousValues;
if (typeof deltaX === 'undefined') deltaX = 0;
if (typeof deltaY === 'undefined') deltaY = 0;
if (Date.now() - timestamp < 200) {
this.setStateFromEvent(this.composeNewState(-deltaX * 10, -deltaY * 10), eventTypes.touchEnd);
}
this.eventPreviousValues = {
...this.eventPreviousValues,
deltaY: 0,
deltaX: 0
};
}
handleScrollbarMove(deltaY, deltaX) {
this.setStateFromEvent(this.composeNewState(deltaX, deltaY));
}
handleScrollbarXPositionChange(position) {
this.scrollXTo(position);
}
handleScrollbarYPositionChange(position) {
this.scrollYTo(position);
}
handleWheel(e) {
if (e.ctrlKey) return;
let deltaY = e.deltaY;
let deltaX = e.deltaX;
if (this.swapWheelAxes) {
[deltaY, deltaX] = [deltaX, deltaY];
}
/*
* WheelEvent.deltaMode can differ between browsers and must be normalized
* e.deltaMode === 0: The delta values are specified in pixels
* e.deltaMode === 1: The delta values are specified in lines
* https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent/deltaMode
*/
if (e.deltaMode === 1) {
deltaY = deltaY * this.lineHeightPx;
deltaX = deltaX * this.lineHeightPx;
}
deltaY = deltaY * this.props.speed;
deltaX = deltaX * this.props.speed;
let newState = this.composeNewState(-deltaX, -deltaY);
if ((newState.topPosition && this.state.topPosition !== newState.topPosition) ||
(newState.leftPosition && this.state.leftPosition !== newState.leftPosition) ||
this.props.stopScrollPropagation) {
e.preventDefault();
e.stopPropagation();
}
this.setStateFromEvent(newState, eventTypes.wheel);
this.focusContent();
}
handleKeyDown(e) {
// only handle if scroll area is in focus
if (e.target.tagName.toLowerCase() !== 'input') {
let deltaY = 0;
let deltaX = 0;
let lineHeight = this.lineHeightPx ? this.lineHeightPx : 10;
switch (e.keyCode) {
case 16: // shift
this.swapWheelAxes = true;
break;
case 33: // page up
deltaY = this.state.containerHeight - lineHeight;
break;
case 34: // page down
deltaY = -this.state.containerHeight + lineHeight;
break;
case 37: // left
deltaX = lineHeight;
break;
case 38: // up
deltaY = lineHeight;
break;
case 39: // right
deltaX = -lineHeight;
break;
case 40: // down
deltaY = -lineHeight;
break;
}
// only compose new state if key code matches those above
if (deltaY !== 0 || deltaX !== 0) {
let newState = this.composeNewState(deltaX, deltaY);
this.setStateFromEvent(newState, eventTypes.keyPress);
}
}
}
handleKeyUp(e) {
// only handle if scroll area is in focus
if (e.target.tagName.toLowerCase() !== 'input') {
if(e.keyCode === 16) {
this.swapWheelAxes = false;
}
}
}
handleMouseDown(e) {
// only handle if scroll area is in focus
if (e.target.tagName.toLowerCase() !== 'input') {
this.dragX = e.screenX;
this.dragY = e.screenY;
this.dragging = this.props.dragging;
}
}
handleMouseUp(e) {
// only handle if scroll area is in focus
if (e.target.tagName.toLowerCase() !== 'input') {
this.dragging = false;
}
}
handleMouseMove(e) {
if(this.dragging) {
let deltaX = (this.dragX - e.screenX)*2;
let deltaY = (this.dragY - e.screenY)*2;
this.dragX = e.screenX;
this.dragY = e.screenY;
if (this.swapWheelAxes) {
[deltaY, deltaX] = [deltaX, deltaY];
}
/*
* WheelEvent.deltaMode can differ between browsers and must be normalized
* e.deltaMode === 0: The delta values are specified in pixels
* e.deltaMode === 1: The delta values are specified in lines
* https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent/deltaMode
*/
if (e.deltaMode === 1) {
deltaY = deltaY * this.lineHeightPx;
deltaX = deltaX * this.lineHeightPx;
}
deltaY = deltaY * this.props.speed;
deltaX = deltaX * this.props.speed;
let newState = this.composeNewState(-deltaX, -deltaY);
if ((newState.topPosition && this.state.topPosition !== newState.topPosition) ||
(newState.leftPosition && this.state.leftPosition !== newState.leftPosition) ||
this.props.stopScrollPropagation) {
e.preventDefault();
e.stopPropagation();
}
this.setStateFromEvent(newState, eventTypes.wheel);
this.focusContent();
}
}
handleWindowResize() {
let newState = this.computeSizes();
newState = this.getModifiedPositionsIfNeeded(newState);
this.setStateFromEvent(newState);
}
composeNewState(deltaX, deltaY) {
let newState = this.computeSizes();
if (this.canScrollY(newState)) {
newState.topPosition = this.computeTopPosition(deltaY, newState);
} else {
newState.topPosition = 0;
}
if (this.canScrollX(newState)) {
newState.leftPosition = this.computeLeftPosition(deltaX, newState);
}
return newState;
}
computeTopPosition(deltaY, sizes) {
let newTopPosition = this.state.topPosition - deltaY;
return this.normalizeTopPosition(newTopPosition, sizes);
}
computeLeftPosition(deltaX, sizes) {
let newLeftPosition = this.state.leftPosition - deltaX;
return this.normalizeLeftPosition(newLeftPosition, sizes);
}
normalizeTopPosition(newTopPosition, sizes) {
if (newTopPosition > sizes.realHeight - sizes.containerHeight) {
newTopPosition = sizes.realHeight - sizes.containerHeight;
}
if (newTopPosition < 0) {
newTopPosition = 0;
}
return newTopPosition;
}
normalizeLeftPosition(newLeftPosition, sizes) {
if (newLeftPosition > sizes.realWidth - sizes.containerWidth) {
newLeftPosition = sizes.realWidth - sizes.containerWidth;
} else if (newLeftPosition < 0) {
newLeftPosition = 0;
}
return newLeftPosition;
}
computeSizes() {
let realHeight = this.content.offsetHeight;
let containerHeight = this.wrapper.offsetHeight;
let realWidth = this.content.offsetWidth;
let containerWidth = this.wrapper.offsetWidth;
return {
realHeight: realHeight,
containerHeight: containerHeight,
realWidth: realWidth,
containerWidth: containerWidth
};
}
setSizesToState() {
let sizes = this.computeSizes();
if (sizes.realHeight !== this.state.realHeight || sizes.realWidth !== this.state.realWidth) {
this.setStateFromEvent(this.getModifiedPositionsIfNeeded(sizes));
}
}
setSizesScrollFromFocusToState(prevProps) {
let sizes = this.computeSizes();
const focusable = ((prevProps || {}).focusX && (prevProps || {}).focusY && (this.props || {}).focusX && (this.props || {}).focusY) &&
(prevProps.focusX !== this.props.focusX || prevProps.focusY !== this.props.focusY);
if (sizes.realHeight !== this.state.realHeight || sizes.realWidth !== this.state.realWidth || focusable) {
this.setStateFromEvent(this.getCenteredPosition(sizes), eventTypes.api);
}
}
scrollTop() {
this.scrollYTo(0);
}
scrollBottom() {
this.scrollYTo((this.state.realHeight - this.state.containerHeight));
}
scrollLeft() {
this.scrollXTo(0);
}
scrollRight() {
this.scrollXTo((this.state.realWidth - this.state.containerWidth));
}
scrollYTo(topPosition) {
if (this.canScrollY()) {
let position = this.normalizeTopPosition(topPosition, this.computeSizes());
this.setStateFromEvent({topPosition: position}, eventTypes.api);
}
}
scrollXTo(leftPosition) {
if (this.canScrollX()) {
let position = this.normalizeLeftPosition(leftPosition, this.computeSizes());
this.setStateFromEvent({leftPosition: position}, eventTypes.api);
}
}
canScrollY(state = this.state) {
let scrollableY = state.realHeight > state.containerHeight;
return scrollableY && this.props.vertical;
}
canScrollX(state = this.state) {
let scrollableX = state.realWidth > state.containerWidth;
return scrollableX && this.props.horizontal;
}
getModifiedPositionsIfNeeded(newState) {
let bottomPosition = newState.realHeight - newState.containerHeight;
if (this.state.topPosition >= bottomPosition) {
newState.topPosition = this.canScrollY(newState) ? positiveOrZero(bottomPosition) : 0;
}
let rightPosition = newState.realWidth - newState.containerWidth;
if (this.state.leftPosition >= rightPosition) {
newState.leftPosition = this.canScrollX(newState) ? positiveOrZero(rightPosition) : 0;
}
return newState;
}
getScrollXFromFocus(focusX) {
let scrollX = (focusX * this.content.offsetWidth) - (this.wrapper.offsetWidth / 2);
if (isNaN(scrollX)) return 0;
return (scrollX < 0 ?
0 : (scrollX > this.content.offsetWidth - this.wrapper.offsetWidth ?
this.content.offsetWidth - this.wrapper.offsetWidth : scrollX));
}
getScrollYFromFocus(focusY) {
let scrollY = (focusY * this.content.offsetHeight) - (this.wrapper.offsetHeight / 2);
if (isNaN(scrollY)) return 0;
return (scrollY < 0 ?
0 : (scrollY > this.content.offsetHeight - this.wrapper.offsetHeight ?
this.content.offsetHeight - this.wrapper.offsetHeight : scrollY));
}
getCenteredPosition(newState) {
newState.topPosition = this.getScrollYFromFocus(this.props.focusY);
newState.leftPosition = this.getScrollXFromFocus(this.props.focusX);
return newState;
}
centerTo(x, y) {
this.setStateFromEvent({
leftPosition: this.getScrollXFromFocus(x),
topPosition: this.getScrollYFromFocus(y),
}, eventTypes.api);
}
focusContent() {
if(this.content) {
findDOMNode(this.content).focus();
}
}
}
ScrollArea.childContextTypes = {
scrollArea: React.PropTypes.object
};
ScrollArea.propTypes = {
className: React.PropTypes.string,
style: React.PropTypes.object,
speed: React.PropTypes.number,
contentClassName: React.PropTypes.string,
contentStyle: React.PropTypes.object,
vertical: React.PropTypes.bool,
verticalContainerStyle: React.PropTypes.object,
verticalScrollbarStyle: React.PropTypes.object,
horizontal: React.PropTypes.bool,
horizontalContainerStyle: React.PropTypes.object,
horizontalScrollbarStyle: React.PropTypes.object,
onScroll: React.PropTypes.func,
contentWindow: React.PropTypes.any,
ownerDocument: React.PropTypes.any,
smoothScrolling: React.PropTypes.bool,
minScrollSize: React.PropTypes.number,
swapWheelAxes: React.PropTypes.bool,
dragging: React.PropTypes.bool,
dragX: React.PropTypes.number,
dragY: React.PropTypes.number,
stopScrollPropagation: React.PropTypes.bool,
focusableTabIndex: React.PropTypes.number,
focusing: React.PropTypes.bool,
focusY: React.PropTypes.number,
focusX: React.PropTypes.number,
};
ScrollArea.defaultProps = {
speed: 1,
vertical: true,
horizontal: true,
smoothScrolling: false,
swapWheelAxes: false,
dragging: false,
dragX: 0,
dragY: 0,
contentWindow: (typeof window === "object") ? window : undefined,
ownerDocument: (typeof document === "object") ? document : undefined,
focusableTabIndex: 1,
focusing: false,
focusY: 0,
focusX: 0,
};