Created
April 26, 2016 04:28
-
-
Save jwaldrip/14b412bf8e6a2ed48e5c2f7c6a4ffea6 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import _ from 'lodash'; | |
import React from 'react'; | |
import ReactDOM from 'react-dom'; | |
import Relay from 'react-relay'; | |
const nullNode = document.createElement('div'); | |
class Infinity extends React.Component { | |
static propTypes = { | |
Component: React.PropTypes.func.isRequired, | |
totalCount: React.PropTypes.number.isRequired, | |
list: React.PropTypes.array.isRequired, | |
fetch: React.PropTypes.func | |
}; | |
static initialState = { | |
startRow: 0, | |
visibleRows: 1, | |
itemsPerRow: 1, | |
rowHeight: 0 | |
}; | |
constructor(...args) { | |
super(...args); | |
this.state = Infinity.initialState; | |
} | |
get calculatedItemMarginLeft() { | |
const firstNode = this.findFirstNode(); | |
return firstNode ? | |
parseInt(getComputedStyle(firstNode).marginLeft, 10) : 0; | |
} | |
get calculatedItemMarginTop() { | |
const firstNode = this.findFirstNode(); | |
return firstNode ? | |
parseInt(getComputedStyle(firstNode).marginTop, 10) : 0; | |
} | |
get calculatedItemMarginRight() { | |
const firstNode = this.findFirstNode(); | |
return firstNode ? | |
parseInt(getComputedStyle(firstNode).marginRight, 10) : 0; | |
} | |
get calculatedItemMarginBottom() { | |
const firstNode = this.findFirstNode(); | |
return firstNode ? | |
parseInt(getComputedStyle(firstNode).marginBottom, 10) : 0; | |
} | |
get calculatedItemHeight() { | |
const firstNode = this.findFirstNode(); | |
const { calculatedItemMarginTop, calculatedItemMarginBottom } = this; | |
const height = firstNode ? | |
parseInt(getComputedStyle(firstNode).height, 10) : 0; | |
return calculatedItemMarginTop + height + calculatedItemMarginBottom; | |
} | |
get calculatedItemWidth() { | |
const firstNode = this.findFirstNode(); | |
const { calculatedItemMarginLeft, calculatedItemMarginRight } = this; | |
const width = firstNode ? | |
parseInt(getComputedStyle(firstNode).width, 10) : 0; | |
return calculatedItemMarginLeft + width + calculatedItemMarginRight; | |
} | |
get calculatedItemsPerRow() { | |
const containerWidth = this.findContainerNode().offsetWidth; | |
const itemWidth = this.calculatedItemWidth; | |
if (itemWidth === 0 || containerWidth === 0) { | |
return 1; | |
} | |
return Math.floor(containerWidth / itemWidth); | |
} | |
get calculatedStartRow() { | |
const containerTop = this.findContainerNode().getBoundingClientRect().top; | |
if (this.calculatedItemHeight === 0 || containerTop > 0) { | |
return 0; | |
} | |
const calculatedRow = Math.floor(-containerTop / this.calculatedItemHeight); | |
const rowAboveZero = Math.max(calculatedRow, 0); | |
const maxPossibleRow = Math.abs(this.totalRowCount - 1); | |
return Math.min(maxPossibleRow, rowAboveZero); | |
} | |
get calculatedVisibleRows() { | |
const containerTop = this.findContainerNode().getBoundingClientRect().top; | |
const containerBottom = this.findContainerNode().getBoundingClientRect().bottom; | |
if (this.calculatedItemHeight === 0 || containerBottom < 0) { | |
return 1; | |
} | |
const top = Math.max(0, containerTop); | |
const bottom = Math.min(window.innerHeight, containerBottom); | |
return Math.max(Math.ceil((bottom - top) / this.calculatedItemHeight), 1); | |
} | |
get endIndex() { | |
const { startRow, visibleRows, itemsPerRow } = this.state; | |
return (startRow + visibleRows) * itemsPerRow; | |
} | |
get startIndex() { | |
const { startRow, itemsPerRow } = this.state; | |
return startRow * itemsPerRow; | |
} | |
get totalRowCount() { | |
return Math.ceil(this.props.totalCount / this.state.itemsPerRow); | |
} | |
get fullList() { | |
return _.times(this.props.totalCount, index => this.props.list[index]); | |
} | |
findNodes() { | |
const { startIndex, endIndex } = this; | |
return this.fullList.slice(startIndex, endIndex).map( | |
(item, index) => { | |
index += startIndex; | |
const ref = `item_${index}`; | |
return ReactDOM.findDOMNode(this.refs[ref]); | |
} | |
).filter(Boolean); | |
} | |
findLastNode() { | |
return this.findNodes().reverse()[0]; | |
} | |
findFirstNode() { | |
return this.findNodes()[0]; | |
} | |
findTopBufferNode() { | |
return ReactDOM.findDOMNode(this.refs.topBuffer) || nullNode; | |
} | |
findBottomBufferNode() { | |
return ReactDOM.findDOMNode(this.refs.bottomBuffer) || nullNode; | |
} | |
findContainerNode() { | |
return ReactDOM.findDOMNode(this.refs.infiniteContainer) || nullNode; | |
} | |
hidePlaceholderAssets() { | |
const { Component } = this.props; | |
Object.keys(this.refs).map( | |
key => this.refs[key] | |
).forEach(node => { | |
const isComponent = node instanceof Component; | |
if (!isComponent) { | |
return; | |
} | |
const { isPlaceholder } = node.props; | |
const domNode = ReactDOM.findDOMNode(node) || nullNode; | |
const isHidden = domNode.style.visibility === 'hidden'; | |
if (isPlaceholder || isHidden) { | |
domNode.style.visibility = isPlaceholder ? 'hidden' : 'visible'; | |
} | |
}); | |
} | |
renderTopBuffer() { | |
const { startRow, rowHeight } = this.state; | |
const height = startRow * rowHeight || 0; | |
return <div ref="topBuffer" style={{ height }} />; | |
} | |
renderVisibleItems() { | |
const { startIndex, endIndex } = this; | |
let { Component, getKey } = this.props; | |
if (!getKey) { | |
getKey = item => item.id; | |
} | |
return this.fullList.slice(startIndex, endIndex).map((item, index) => { | |
let isPlaceholder = false; | |
index += startIndex; | |
if (item === undefined) { | |
item = this.fullList[0]; | |
isPlaceholder = true; | |
} | |
const key = _.uniqueId('inifiniteItem'); | |
const ref = `item_${index}`; | |
return <Component key={key} ref={ref} isPlaceholder={isPlaceholder} {...item} />; | |
}); | |
} | |
renderBottomBuffer() { | |
const { startRow, visibleRows, rowHeight } = this.state; | |
const { totalRowCount } = this; | |
const endRow = visibleRows + startRow; | |
const height = endRow > totalRowCount ? 0 : (totalRowCount - endRow) * rowHeight || 0; | |
return <div ref="bottomBuffer" style={{ height }} />; | |
} | |
trackResize = () => { | |
this.setState(Infinity.initialState); | |
}; | |
trackScroll = () => { | |
clearTimeout(this.scroll); | |
this.scroll = setTimeout(this.calculatePositions.bind(this), 10); | |
}; | |
updateStateIfChanged(newState) { | |
if (Object.keys(newState).find(k => this.state[k] !== newState[k])) { | |
this.setState(newState); | |
} | |
} | |
calculatePositions() { | |
this.hidePlaceholderAssets(); | |
const { | |
calculatedStartRow, | |
calculatedItemHeight, | |
calculatedVisibleRows, | |
calculatedItemsPerRow, | |
endIndex, | |
} = this; | |
// Fetch more if needed | |
const allowFetch = () => { this.fetching = false; }; | |
const { fetch: fetchMore, list, totalCount } = this.props; | |
if (endIndex >= list.length && list.length <= totalCount && !this.fetching) { | |
this.fetching = true; | |
fetchMore().then(allowFetch).catch(allowFetch); | |
} | |
this.updateStateIfChanged({ | |
startRow: calculatedStartRow, | |
visibleRows: calculatedVisibleRows, | |
rowHeight: calculatedItemHeight, | |
itemsPerRow: calculatedItemsPerRow | |
}); | |
} | |
componentWillMount() { | |
window.addEventListener('resize', this.trackResize); | |
window.addEventListener('scroll', this.trackScroll); | |
} | |
componentWillUnmount() { | |
window.removeEventListener('resize', this.trackResize); | |
window.removeEventListener('scroll', this.trackScroll); | |
} | |
componentDidMount() { | |
this.calculatePositions(); | |
} | |
componentDidUpdate() { | |
this.calculatePositions(); | |
} | |
render() { | |
return ( | |
<div ref="infiniteContainer"> | |
{this.renderTopBuffer()} | |
{this.renderVisibleItems()} | |
{this.renderBottomBuffer()} | |
</div> | |
); | |
} | |
} | |
class Asset extends React.Component { | |
render() { | |
const style = { | |
background: `center center no-repeat url(${this.props.asset.thumbnail_url})`, | |
width: 250, | |
height: 180, | |
margin: 10, | |
textAlign: 'center', | |
display: 'inline-block', | |
border: '1px solid black' | |
}; | |
return ( | |
<div style={style}> | |
{this.props.asset.apiId} | |
</div> | |
); | |
} | |
} | |
class Section extends React.Component { | |
renderAsset({ node: asset }) { | |
return ( | |
<Asset asset={asset} /> | |
); | |
} | |
loadMoreAssets = () => { | |
const { relay } = this.props; | |
const { assetLimit } = relay.variables; | |
return new Promise((resolve, reject) => { | |
relay.setVariables( | |
{ assetLimit: assetLimit + 50 }, ({ done, error, aborted, stale }) => { | |
if (done || stale) { | |
return resolve(); | |
} | |
if (error) { | |
return reject(error); | |
} | |
if (aborted) { | |
return reject(new Error('the request was aborted')); | |
} | |
} | |
); | |
}); | |
}; | |
loadMoreAssetsWithTimeout = () => { | |
this.sectionLoadTimeout = setTimeout(this.loadMoreAssets, 100); | |
}; | |
componentWillUnmount() { | |
clearTimeout(this.sectionLoadTimeout); | |
} | |
loadAssetsUntilLoaded() { | |
if (this.props.section.assets.pageInfo.hasNextPage) { | |
return this.loadMoreAssets().then( | |
this.loadAssetsUntilLoaded.bind(this) | |
); | |
} | |
return Promise.resolve(); | |
} | |
componentDidMount() { | |
this.loadAssetsUntilLoaded(); | |
} | |
render() { | |
const { name, assets, number_of_assets: totalCount } = this.props.section; | |
return ( | |
<div> | |
<h1>{name}</h1> | |
<Infinity | |
Component={Asset} | |
list={assets.edges.map(({ node }) => ({ asset: node }))} | |
totalCount={totalCount} | |
fetch={this.loadMoreAssets} | |
/> | |
</div> | |
); | |
} | |
} | |
class InfinityPage extends React.Component { | |
renderSection({ node: section }) { | |
return ( | |
<SectionRelayContainer section={section} /> | |
); | |
} | |
loadMoreSections = () => { | |
if (this.props.brandfolder.sections.pageInfo.hasNextPage) { | |
const { sectionLimit } = this.props.relay.variables; | |
this.props.relay.setVariables( | |
{ sectionLimit: sectionLimit + 5 }, ({ done }) => { | |
if (done) { | |
this.loadMoreSectionsWithTimeout(); | |
} | |
} | |
); | |
} | |
}; | |
loadMoreSectionsWithTimeout = () => { | |
this.sectionLoadTimeout = setTimeout(this.loadMoreSections, 100); | |
}; | |
componentWillUnmount() { | |
clearTimeout(this.sectionLoadTimeout); | |
} | |
componentDidMount() { | |
this.loadMoreSectionsWithTimeout(); | |
} | |
renderSections() { | |
return this.props.brandfolder.sections.edges.map( | |
({ node: section }) => <SectionRelayContainer key={section.id} section={section} /> | |
); | |
} | |
render() { | |
return ( | |
<div> | |
{this.renderSections()} | |
</div> | |
); | |
} | |
} | |
const SectionRelayContainer = Relay.createContainer(Section, { | |
initialVariables: { | |
assetLimit: 50, | |
}, | |
fragments: { | |
section: () => Relay.QL` | |
fragment on Section { | |
id | |
name | |
number_of_assets | |
assets(first: $assetLimit) { | |
pageInfo { | |
hasNextPage | |
} | |
edges { | |
node { | |
id | |
apiId | |
thumbnail_url | |
} | |
} | |
} | |
} | |
` | |
} | |
}); | |
export const InfinityPageRelayContainer = Relay.createContainer(InfinityPage, { | |
initialVariables: { | |
sectionLimit: 5 | |
}, | |
fragments: { | |
brandfolder: () => Relay.QL` | |
fragment on Brandfolder { | |
sections(first: $sectionLimit) { | |
pageInfo { | |
hasNextPage | |
} | |
edges { | |
node { | |
id | |
name | |
${SectionRelayContainer.getFragment('section')} | |
} | |
} | |
} | |
} | |
` | |
} | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment