diff --git a/index.js b/index.js index b4b69ba..a9e41c9 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,6 @@ -var React = require('react'); -//@TODO remove lodash (currently used for throttling) -var _ = require('lodash'); -var Immutable = require('immutable'); +var React = require('react') +var _ = require('lodash') +var Immutable = require('immutable') console.debug = function(){ // console.log.apply(console, arguments) @@ -9,178 +8,6 @@ console.debug = function(){ var Everscroll = React.createClass({ - /** - * Baseline State - * @type {Object} - */ - _initialState: { - listOffset: 0, - keyStart: 0, - frontHeight: 0, - backHeight: 0, - }, - - /** - * Transient State - */ - _prependOffset: 0, - _targetCursorOffset: 0, - _initScroll: true, - _scrollAfterUpdate: false, - _seekFront: false, - - /** - * reset container scroll to top or bottom depending on direction - */ - _initScrollTop: function () { - this._initScroll = false; - var containerEl = this.refs.root.getDOMNode(); - if (this.props.reverse) { - containerEl.scrollTop = containerEl.scrollHeight - containerEl.clientHeight; - } else { - containerEl.scrollTop = 0; - } - }, - - /** - * determine ref name for cursor (middlemost rendered element on the screen) - * @return {String} - */ - _calcCursorRef: function() { - - //get container - var containerEl = this.refs.root.getDOMNode(); - var threshold = containerEl.scrollTop + containerEl.clientHeight / 2; - - var clearThreshold = (ref) => { - var node = this.refs[ref].getDOMNode(); - //If reverse subtract offsetheight (so threshold relative to bottom of node instead of top) - return node.offsetTop > threshold - (this.props.reverse ? node.offsetHeight : 0); - } - - var refRange = this._getRefRenderRange(); - - var cursorRef = - refRange - .filter(ref => this.refs[ref]) - .takeUntil(clearThreshold) - .last() - || - refRange.first(); - - return cursorRef; - - }, - - /** - * determine if new cursor is needed and set if necessary - */ - _setCursor: function() { - - var cursorRef = this._calcCursorRef(); - - if (this.state.cursorRef === cursorRef) { - return; - } - - this.setState({ - cursorRef: cursorRef, - }); - }, - - /** - * get renderable ref Range in incremental order - * Front of list to Back of list - * @return {Immutable.Seq} - */ - _getRefRange: function () { - var rangeStart = this.state.listOffset; - var rangeEnd = rangeStart + Math.min(this.props.renderCount, this.props.rowIndex.length); - - return Immutable.Range(rangeStart, rangeEnd) - }, - - /** - * get renderable ref Range in render order - * Top of container to Bottom of container - * @return {Immutable.Seq} - */ - _getRefRenderRange: function() { - var refRange = this._getRefRange(); - - return this.props.reverse ? refRange.reverse() : refRange - }, - - /** - * recycled key Range for DOM reuse - * @return {Immutable.List} - */ - //@TODO determine if keys provide any real performance benefit - _getKeyList: function() { - var keyStart = this.state.keyStart; - var basicRange = Immutable.Range(0, this.props.renderCount); - - return basicRange.slice(keyStart).toList().concat(basicRange.slice(0, keyStart).toList()) - }, - - /** - * call renderRowHanlder for given rowIndex value (ID) - */ - _renderRow: function(ID, index){ - return this.props.renderRowHandler(ID, index); - }, - - /** - * determine if render range needs to shift and update state accordingly - */ - _handleScroll: function(){ - - if (this.props.rowIndex.length < this.props.renderCount) return; - - var reverse = this.props.reverse - - var cursorRef = this._calcCursorRef() - var renderRange = this._getRefRenderRange() - var refRange = this._getRefRange() - - var rowsAbove = Math.abs(renderRange.first() - cursorRef) - var rowsBelow = Math.abs(cursorRef - renderRange.last()) - - var direction = rowsAbove >= rowsBelow ? 1 : -1; - direction = reverse ? direction * -1 : direction; - var offsetAdj = Math.floor(Math.abs(rowsAbove - rowsBelow) / 2) * direction - - var minOffset = 0 - var maxOffset = this.props.rowIndex.length - this.props.renderCount - - var adjustedOffset = Math.min(Math.max(this.state.listOffset + offsetAdj, minOffset), maxOffset); - var adjustedKeyStart = (this.state.keyStart + this.state.listOffset - adjustedOffset) % this.props.renderCount - - if (adjustedOffset === maxOffset && this.state.listOffset !== adjustedOffset) { - process.nextTick(this.props.onEndReached) - } - - //@TODO come up with a better way seed top and bottom spacesrs and maintain consistency during the scroll - // consider caching rendered row heights rather than approximating - var averageHeight = this.refs.renderRows.getDOMNode().scrollHeight / renderRange.count() - - var shift = adjustedOffset - this.state.listOffset - var backSpacerHeight = (this.props.rowIndex.length - refRange.last() - 1 - shift) * averageHeight - var frontSpacerHeight = (refRange.first() + shift) * averageHeight - - var [topSpacerHeight, bottomSpacerHeight] = this.props.reverse - ? [backSpacerHeight, frontSpacerHeight] - : [frontSpacerHeight, backSpacerHeight] - - this.setState({ - cursorRef: cursorRef, - listOffset: adjustedOffset, - keyStart: adjustedKeyStart, - frontHeight: frontSpacerHeight, - backHeight: backSpacerHeight, - }) - }, - propTypes: { rowIndex: React.PropTypes.array.isRequired, idKey: React.PropTypes.string, @@ -188,40 +15,48 @@ var Everscroll = React.createClass({ reverse: React.PropTypes.bool, renderCount: React.PropTypes.number, onEndReached: React.PropTypes.func, + onTopCursorChange: React.PropTypes.func, + onBottomCursorChange: React.PropTypes.func, topCap: React.PropTypes.object, - bottomCap: React.PropTypes.object + bottomCap: React.PropTypes.object, + seekFrontThreshold: React.PropTypes.number }, getDefaultProps: function() { return { idKey: 'ID', renderRowHandler: function(ID){ - return (
{ID}
) + return React.DOM.div({className:'row'}, ID) }, reverse: false, renderCount: 30, throttle: 100, loadBuffer: 30, - onEndReached: function () {} + onEndReached: function () {}, + seekFrontThreshold: 20 } }, getInitialState: function() { - return this._initialState; + console.debug('EVERSCROLL - getInitialState') + return this.initialState() }, componentWillReceiveProps: function(nextProps){ + console.debug('EVERSCROLL - componentWillReceiveProps', nextProps) var oldRowIndex = this.props.rowIndex var newRowIndex = nextProps.rowIndex // reset scroll and render if we go from now rows to rows if(oldRowIndex && oldRowIndex.length === 0 && newRowIndex && newRowIndex.length > 0){ - this.setState(this._initialState); + console.debug('EVERSCROLL - CWRP - FILL - empty to non-empty index') + this.setState(this.initialState()) this._initScroll = true } // detect prepend and adjust listOffset if necessary else if (oldRowIndex[0] !== newRowIndex[0]) { + console.debug('EVERSCROLL - CWRP - PREPEND') var prependedMessageCount = Immutable .List(newRowIndex) @@ -232,10 +67,10 @@ var Everscroll = React.createClass({ //@TODO discuss, how best to seek front, and how to set threshold var containerEl = this.refs.root.getDOMNode() - var seekFrontThreshold = 20 - if( (!this.props.reverse && containerEl.scrollTop < seekFrontThreshold) || (this.props.reverse && containerEl.scrollTop + containerEl.offsetHeight > containerEl.scrollHeight-seekFrontThreshold) ){ + if( this.props.seekFrontThreshold !== -1 && ((!this.props.reverse && containerEl.scrollTop < this.props.seekFrontThreshold) || (this.props.reverse && containerEl.scrollTop + containerEl.offsetHeight > containerEl.scrollHeight-this.props.seekFrontThreshold)) ){ this._seekFront = true } + this._scrollAfterUpdate = true //@TODO should detect if old message root is not in new row index and handle... this.setState({ @@ -244,51 +79,72 @@ var Everscroll = React.createClass({ }) this._prependOffset = prependedMessageCount - - - } else if (oldRowIndex[oldRowIndex.length - 1] !== newRowIndex[newRowIndex.length - 1]) { + console.debug('EVERSCROLL - CWRP - APPEND') this.setState({listOffset: this.state.listOffset + 1}) } - //@TODO more elegant handling? - //this._scrollAfterUpdate = true - if (this.props.renderCount >= newRowIndex.length) { process.nextTick(this.props.onEndReached) + } + }, + initialState: function (){ + //@TODO generalize this with the seek method + if(this.props.initialSeekIndex){ + var self = this + this._seek = {index: this.props.initialSeekIndex, pixelOffset: this.props.initialSeekPixelOffset || 0} + process.nextTick(function(){ + self.handleScroll() + }) + } + + var initialOffset = this.props.initialSeekIndex ? Math.max(this.props.initialSeekIndex-this.props.renderCount/2, 0) : 0 + return { + listOffset: initialOffset, + keyStart: 0, + frontHeight: 0, + backHeight: 0, + cursorRef: 0 + } }, shouldComponentUpdate: function(nextProps, nextState){ + console.debug('EVERSCROLL - shouldComponentUpdate') //@TODO: implement to prevent uncessary renders on prop changes return true }, componentDidMount: function() { - this._handleScroll = _.throttle(this._handleScroll, this.props.throttle) - this._initScrollTop() + console.debug('EVERSCROLL - componentDidMount') + this.handleScroll = _.throttle(this.handleScroll, this.props.throttle) + this.calcAndTriggerCursors = _.throttle(this.calcAndTriggerCursors, 1000) + this.initScrollTop() }, componentWillUpdate: function(nextProps, nextState){ + console.debug('EVERSCROLL - componentWillUpdate & nextState.topCursorRef', nextState.topCursorRef, nextState) if (nextState.cursorRef){ var containerEl = this.getDOMNode() var refOffset = this._prependOffset || 0 this._prependOffset = 0; console.debug('CWU - cursorRef & refOffset', nextState.cursorRef, refOffset) - this._targetCursorOffset = this.refs[nextState.cursorRef - refOffset].getDOMNode().offsetTop - containerEl.scrollTop - console.debug('CWU - _targetCursorOffset', this._targetCursorOffset) + this.targetCursorOffset = this.refs[nextState.cursorRef - refOffset].getDOMNode().offsetTop - containerEl.scrollTop + console.debug('CWU - targetCursorOffset', this.targetCursorOffset) } }, componentDidUpdate: function(prevProps, prevState){ + console.debug('EVERSCROLL - componentDidUpdate') + + var containerEl = this.getDOMNode() if (this.state.cursorRef){ - var containerEl = this.getDOMNode(); var currentCursorOffset = this.refs[this.state.cursorRef].getDOMNode().offsetTop - containerEl.scrollTop - var adjustment = currentCursorOffset - this._targetCursorOffset + var adjustment = currentCursorOffset - this.targetCursorOffset console.debug('CDU - cursorRef', this.state.cursorRef) console.debug('CDU - currentCursorOffset', currentCursorOffset) console.debug('CDU - scrollTop adjustment', adjustment) @@ -296,12 +152,13 @@ var Everscroll = React.createClass({ } if (this._initScroll) { - this._initScrollTop(); + this.initScrollTop() + this._initScroll = false } //@TODO possible only call when requested instead of every update if(this._scrollAfterUpdate){ - this._handleScroll() + this.handleScroll() this._scrollAfterUpdate = false } @@ -310,50 +167,247 @@ var Everscroll = React.createClass({ this._seekFront = false } - this._setCursor() + if(this._seek){ + var pixelOffset = this._seek.pixelOffset + var seekIndex = this._seek.index + if(this.refs[seekIndex]){ + var seekEl = this.refs[seekIndex].getDOMNode() + if(containerEl){ + containerEl.scrollTop = seekEl.offsetTop - containerEl.offsetTop + (this.props.reverse ? pixelOffset + seekEl.offsetHeight - containerEl.offsetHeight : - pixelOffset) + this._seek = undefined + } + } + } + + // this.setCursors() + }, + + initScrollTop: function () { + var containerEl = this.refs.root.getDOMNode() + if (this.props.reverse) { + containerEl.scrollTop = containerEl.scrollHeight - containerEl.clientHeight + } else { + containerEl.scrollTop = 0 + } + }, + + handleScroll: function(){ + console.debug('handleScroll') + this.calcAndTriggerCursors() + console.debug('this.props.rowIndex.length', this.props.rowIndex.length) + + if (this.props.rowIndex.length < this.props.renderCount) return; + + var reverse = this.props.reverse + + var cursorRef = this.calcCursorRef() + var renderRange = this.getRefRenderRange() + var refRange = this.getRefRange() + + var rowsAbove = Math.abs(renderRange.first() - cursorRef) + var rowsBelow = Math.abs(cursorRef - renderRange.last()) + + var direction = rowsAbove >= rowsBelow ? 1 : -1; + direction = reverse ? direction * -1 : direction; + var offsetAdj = Math.floor(Math.abs(rowsAbove - rowsBelow) / 2) * direction + + var minOffset = 0 + var maxOffset = this.props.rowIndex.length - this.props.renderCount + + var adjustedOffset = Math.min(Math.max(this.state.listOffset + offsetAdj, minOffset), maxOffset); + var adjustedKeyStart = (this.state.keyStart + this.state.listOffset - adjustedOffset) % this.props.renderCount + + if (adjustedOffset === maxOffset && this.state.listOffset !== adjustedOffset) { + process.nextTick(this.props.onEndReached) + } + + var averageHeight = this.refs.renderRows.getDOMNode().scrollHeight / renderRange.count() + console.debug('averageHeight', averageHeight) + + var shift = adjustedOffset - this.state.listOffset + var backSpacerHeight = (this.props.rowIndex.length - refRange.last() - 1 - shift) * averageHeight + var frontSpacerHeight = (refRange.first() + shift) * averageHeight + + var topSpacerHeight = this.props.reverse ? backSpacerHeight : frontSpacerHeight + var bottomSpacerHeight = this.props.reverse ? frontSpacerHeight : backSpacerHeight + + console.debug('handleScroll state ', { + cursorRef: cursorRef, + listOffset: adjustedOffset, + keyStart: adjustedKeyStart, + }) + + this.setState({ + cursorRef: cursorRef, + listOffset: adjustedOffset, + keyStart: adjustedKeyStart, + frontHeight: frontSpacerHeight, + backHeight: backSpacerHeight, + }) + }, + + setCursors: function() { + console.debug('setCursor') + + var cursorRef = this.calcCursorRef() + + if (this.state.cursorRef === cursorRef) { + console.debug('cursors are the same, no state update') + return + } + + console.debug('new cursor', { + cursorRef: cursorRef, + }) + + this.setState({ + cursorRef: cursorRef, + }) + }, + + + /** + * Calculates the top and bottom custors, and calls onChange callback props if present + * Exists purely for the efficient calculation and triggering on onChange props, and + * does not have any internal side effects. + */ + calcAndTriggerCursors: function() { + //If no listeners, return immediately + if(!this.props.onTopCursorChange && !this.props.onBottomCursorChange ){ return } + + var containerEl = this.refs.root.getDOMNode() + + var topThreshold = containerEl.scrollTop + var topCursor = null + var bottomThreshold = containerEl.scrollTop + containerEl.offsetHeight + var bottomCursor = null + + var self = this + var firstClear = null + var clearTopThreshold = function(ref){ + var node = self.refs[ref].getDOMNode() + return node.offsetTop > topThreshold + } + var clearBottomThreshold = function(ref){ + if(firstClear === null){ firstClear = ref } + var node = self.refs[ref].getDOMNode() + // console.log('check bot', node.offsetTop + node.offsetHeight, bottomThreshold) + return (node.offsetTop + node.offsetHeight) > bottomThreshold + } + + var refRange = this.getRefRenderRange() + var visibleRange = + refRange + .skipUntil(clearTopThreshold) + .takeUntil(clearBottomThreshold) + // .filter(function(ref){return self.refs[ref]}) + + var topCursorRef = visibleRange.first() || firstClear + var bottomCursorRef = visibleRange.last() || firstClear + 1 + + if(this.props.onTopCursorChange){ this.props.onTopCursorChange(this.props.rowIndex[topCursorRef], topCursorRef) } + if(this.props.onBottomCursorChange){ this.props.onBottomCursorChange(this.props.rowIndex[bottomCursorRef], bottomCursorRef) } + }, + + calcCursorRef: function() { + //get container + var containerEl = this.refs.root.getDOMNode() + var threshold = containerEl.scrollTop + containerEl.clientHeight / 2 + + var self = this + var clearThreshold = function(ref){ + var node = self.refs[ref].getDOMNode() + //If reverse subtract offsetheight (so threshold relative to bottom of node instead of top) + return node.offsetTop > threshold - (self.props.reverse ? node.offsetHeight : 0) + } + + var refRange = this.getRefRenderRange() + + var cursorRef = + refRange + .filter(function(ref){return self.refs[ref]}) + .takeUntil(clearThreshold) + .last() + || + refRange.first() + + return cursorRef + }, + + getRefRange: function () { + var rangeStart = this.state.listOffset + var rangeEnd = rangeStart + Math.min(this.props.renderCount, this.props.rowIndex.length) + + return Immutable.Range(rangeStart, rangeEnd) + }, + + getRefRenderRange: function() { + var refRange = this.getRefRange() + return this.props.reverse ? refRange.reverse() : refRange + }, + + getKeyList: function() { + var keyStart = this.state.keyStart + + var basicRange = Immutable.Range(0, this.props.renderCount) + + return basicRange.slice(keyStart).toList().concat(basicRange.slice(0, keyStart).toList()) + }, + + renderRow: function(ID, index){ + return this.props.renderRowHandler(ID, index) + }, + + seek: function(index, pixelOffset){ + var self = this + this.setState({listOffset: index, cursorRef: null}) + this._seek = {index: index, pixelOffset: pixelOffset || 0} + process.nextTick(function(){ + self.handleScroll() + }) }, render: function() { - var reverse = this.props.reverse && true + var rev = this.props.reverse && true - var refRange = this._getRefRange() + var refRange = this.getRefRange() var rows = this.props.rowIndex.slice(refRange.first(), refRange.last() + 1) - var refs = this._getRefRenderRange().toArray() - var keys = this._getKeyList().map(val => "everrow-" + val).toArray() + var refs = this.getRefRenderRange().toArray() + var keys = this.getKeyList().map(function(val){return "everrow-" + val}).toArray() - var topSpacerHeight = reverse ? this.state.backHeight : this.state.frontHeight; - var bottomSpacerHeight = reverse ? this.state.frontHeight : this.state.backHeight; + var topSpacerHeight = rev ? this.state.backHeight : this.state.frontHeight; + var bottomSpacerHeight = rev ? this.state.frontHeight : this.state.backHeight; - if (reverse) { - rows.reverse(); + if (rev) { + rows.reverse() } - - var renderRows = rows.map((ID, index) =>{ + + console.debug('EVERSCROLL - render', this.state.listOffset, refRange.first()) + + var self = this + var renderRows = rows.map(function(ID, index){ return ( -
- {this._renderRow(ID, index)} -
+ React.DOM.div({style:{overflow: "hidden"}, key:keys[index], ref:refs[index]}, + self.renderRow(ID, refs[index]) + ) ) }) return ( -
-
- {this.props.reverse ? this.props.backCap : this.props.frontCap} -
-
-
- {renderRows} -
-
-
- {!this.props.reverse ? this.props.backCap : this.props.frontCap} -
-
+ React.DOM.div({ref:"root", style: this.props.style, className: this.props.className, key: this.props.key, onScroll: this.handleScroll}, + React.DOM.div({ref:"topCap"}, + (this.props.reverse ? this.props.backCap : this.props.frontCap)), + React.DOM.div({ref:"topSpacer", style: {height: topSpacerHeight} }), + React.DOM.div({key:"renderRows", ref:"renderRows"}, + renderRows + ), + React.DOM.div({ref:"bottomSpacer", style:{height: bottomSpacerHeight} }), + React.DOM.div({ref:"bottomCap"}, + (!this.props.reverse ? this.props.backCap : this.props.frontCap)) + ) ) } - }) module.exports = Everscroll