UNPKG

react-reflex

Version:

Flex layout component for advanced React web applications

880 lines (677 loc) 22.2 kB
/////////////////////////////////////////////////////////// // ReflexContainer // By Philippe Leefsma // December 2016 // /////////////////////////////////////////////////////////// import ReflexSplitter from './ReflexSplitter' import ReflexEvents from './ReflexEvents' import {getDataProps} from './utilities' import PropTypes from 'prop-types' import React from 'react' import './Polyfills' export default class ReflexContainer extends React.Component { ///////////////////////////////////////////////////////// // orientation: Orientation of the layout container // valid values are ['horizontal', 'vertical'] // maxRecDepth: Maximun recursion depth to solve initial flex // of layout elements based on user provided values // className: Space separated classnames to apply custom styles // to the layout container // style: allows passing inline style to the container ///////////////////////////////////////////////////////// static propTypes = { windowResizeAware: PropTypes.bool, orientation: PropTypes.oneOf([ 'horizontal', 'vertical' ]), maxRecDepth: PropTypes.number, className: PropTypes.string, style: PropTypes.object } static defaultProps = { orientation: 'horizontal', windowResizeAware: false, maxRecDepth: 100, className: '', style: {} } constructor (props) { super (props) this.events = new ReflexEvents() this.children = [] this.state = { flexData: [] } this.ref = React.createRef() } componentDidMount () { const flexData = this.computeFlexData() const {windowResizeAware} = this.props if (windowResizeAware) { window.addEventListener( 'resize', this.onWindowResize) } this.setState ({ windowResizeAware, flexData }) this.events.on( 'element.size', this.onElementSize) this.events.on( 'startResize', this.onStartResize) this.events.on( 'stopResize', this.onStopResize) this.events.on( 'resize', this.onResize) } componentWillUnmount () { this.events.off() window.removeEventListener( 'resize', this.onWindowResize) } getValidChildren (props = this.props) { return this.toArray(props.children).filter((child) => { return !!child }) } componentDidUpdate (prevProps, prevState) { const children = this.getValidChildren(this.props) if ((children.length !== this.state.flexData.length) || (prevProps.orientation !== this.props.orientation) || this.flexHasChanged(prevProps)) { const flexData = this.computeFlexData( children, this.props) this.setState({ flexData }) } if (this.props.windowResizeAware !== this.state.windowResizeAware) { !this.props.windowResizeAware ? window.removeEventListener('resize', this.onWindowResize) : window.addEventListener('resize', this.onWindowResize) this.setState({ windowResizeAware: this.props.windowResizeAware }) } } // UNSAFE_componentWillReceiveProps(props) { // const children = this.getValidChildren(props) // if (children.length !== this.state.flexData.length || // props.orientation !== this.props.orientation || // this.flexHasChanged(props)) // { // const flexData = this.computeFlexData( // children, props) // this.setState({ // flexData // }); // } // if (props.windowResizeAware !== this.state.windowResizeAware) { // !props.windowResizeAware // ? window.removeEventListener('resize', this.onWindowResize) // : window.addEventListener('resize', this.onWindowResize) // this.setState({ // windowResizeAware: props.windowResizeAware // }) // } // } ///////////////////////////////////////////////////////// // attempts to preserve current flex on window resize // ///////////////////////////////////////////////////////// onWindowResize = () => { this.setState({ flexData: this.computeFlexData() }) } ///////////////////////////////////////////////////////// // Check if flex has changed: this allows updating the // component when different flex is passed as property // to one or several children // ///////////////////////////////////////////////////////// flexHasChanged (prevProps) { const prevChildrenFlex = this.getValidChildren(prevProps).map((child) => { return child.props.flex || 0 }) const childrenFlex = this.getValidChildren().map((child) => { return child.props.flex || 0 }) return !childrenFlex.every((flex, idx) => { return flex === prevChildrenFlex[idx] }) } ///////////////////////////////////////////////////////// // Returns size of a ReflexElement // ///////////////////////////////////////////////////////// getSize (element) { const ref = element?.props.ref || element?.ref const domElement = ref?.current switch (this.props.orientation) { case 'horizontal': return domElement?.offsetHeight ?? 0 case 'vertical': default: return domElement?.offsetWidth ?? 0 } } ///////////////////////////////////////////////////////// // Computes offset from pointer position // ///////////////////////////////////////////////////////// getOffset (pos, domElement) { const { top, bottom, left, right } = domElement.getBoundingClientRect() switch (this.props.orientation) { case 'horizontal': { const offset = pos.clientY - this.previousPos if (offset > 0) { if (pos.clientY >= top) { return offset } } else { if (pos.clientY <= bottom) { return offset } } break } case 'vertical': default: { const offset = pos.clientX - this.previousPos if (offset > 0) { if (pos.clientX > left) { return offset } } else { if (pos.clientX < right) { return offset } } } break } return 0 } ///////////////////////////////////////////////////////// // Handles startResize event // ///////////////////////////////////////////////////////// onStartResize = (data) => { const pos = data.event.changedTouches ? data.event.changedTouches[0] : data.event switch (this.props.orientation) { case 'horizontal': document.body.classList.add('reflex-row-resize') this.previousPos = pos.clientY break case 'vertical': default: document.body.classList.add('reflex-col-resize') this.previousPos = pos.clientX break } this.elements = [ this.children[data.index - 1], this.children[data.index + 1] ] this.emitElementsEvent( this.elements, 'onStartResize') } ///////////////////////////////////////////////////////// // Handles splitter resize event // ///////////////////////////////////////////////////////// onResize = (data) => { const pos = data.event.changedTouches ? data.event.changedTouches[0] : data.event const offset = this.getOffset( pos, data.domElement) switch (this.props.orientation) { case 'horizontal': this.previousPos = pos.clientY break case 'vertical': default: this.previousPos = pos.clientX break } if (offset) { const availableOffset = this.computeAvailableOffset( data.index, offset) if (availableOffset) { this.elements = this.dispatchOffset( data.index, availableOffset) this.adjustFlex(this.elements) this.setState({ resizing: true }, () => { this.emitElementsEvent( this.elements, 'onResize') }) } } } ///////////////////////////////////////////////////////// // Handles stopResize event // ///////////////////////////////////////////////////////// onStopResize = (data) => { document.body.classList.remove('reflex-row-resize') document.body.classList.remove('reflex-col-resize') const resizedRefs = this.elements ? this.elements.map(element => { return element.props.ref || element.ref }) : []; const elements = this.children.filter(child => { return !ReflexSplitter.isA(child) && resizedRefs.includes(child.props.ref || child.ref) }) this.emitElementsEvent( elements, 'onStopResize') this.setState({ resizing: false }) } ///////////////////////////////////////////////////////// // Handles element size modified event // ///////////////////////////////////////////////////////// onElementSize = (data) => { return new Promise((resolve) => { try { const idx = data.index const size = this.getSize(this.children[idx]) const offset = data.size - size const dir = data.direction const splitterIdx = idx + dir const availableOffset = this.computeAvailableOffset( splitterIdx, dir * offset) this.elements = null if (availableOffset) { this.elements = this.dispatchOffset( splitterIdx, availableOffset) this.adjustFlex(this.elements) } this.setState(this.state, () => { this.emitElementsEvent( this.elements, 'onResize') resolve() }) } catch (ex) { // TODO handle exception ... console.log(ex) } }) } ///////////////////////////////////////////////////////// // Adjusts flex after a dispatch to make sure // total flex of modified elements remains the same // ///////////////////////////////////////////////////////// adjustFlex (elements) { const diffFlex = elements.reduce((sum, element) => { const idx = element.props.index const previousFlex = element.props.flex const nextFlex = this.state.flexData[idx].flex return sum + (previousFlex - nextFlex) / elements.length }, 0) elements.forEach((element) => { this.state.flexData[element.props.index].flex += diffFlex }) } ///////////////////////////////////////////////////////// // Returns available offset for a given raw offset value // This checks how much the panes can be stretched and // shrink, then returns the min // ///////////////////////////////////////////////////////// computeAvailableOffset (idx, offset) { const stretch = this.computeAvailableStretch( idx, offset) const shrink = this.computeAvailableShrink( idx, offset) const availableOffset = Math.min(stretch, shrink) * Math.sign(offset) return availableOffset } ///////////////////////////////////////////////////////// // Returns true if the next splitter than the one at idx // can propagate the drag. This can happen if that // next element is actually a splitter and it has // propagate=true property set // ///////////////////////////////////////////////////////// checkPropagate (idx, direction) { if (direction > 0) { if (idx < this.children.length - 2) { const child = this.children[idx + 2] const typeCheck = ReflexSplitter.isA(child) return typeCheck && child.props.propagate } } else { if (idx > 2) { const child = this.children[idx - 2] const typeCheck = ReflexSplitter.isA(child) return typeCheck && child.props.propagate } } return false } ///////////////////////////////////////////////////////// // Recursively computes available stretch at splitter // idx for given raw offset // ///////////////////////////////////////////////////////// computeAvailableStretch (idx, offset) { const childIdx = offset < 0 ? idx + 1 : idx - 1 const child = this.children[childIdx] const size = this.getSize(child) const maxSize = child?.props.maxSize ?? 0 const availableStretch = maxSize - size if (availableStretch < Math.abs(offset)) { if (this.checkPropagate(idx, -1 * offset)) { const nextOffset = Math.sign(offset) * (Math.abs(offset) - availableStretch) return availableStretch + this.computeAvailableStretch( offset < 0 ? idx + 2 : idx - 2, nextOffset) } } return Math.min(availableStretch, Math.abs(offset)) } ///////////////////////////////////////////////////////// // Recursively computes available shrink at splitter // idx for given raw offset // ///////////////////////////////////////////////////////// computeAvailableShrink (idx, offset) { const childIdx = offset > 0 ? idx + 1 : idx -1 const child = this.children[childIdx] const size = this.getSize(child) const minSize = Math.max( child?.props.minSize ?? 0, 0) const availableShrink = size - minSize if (availableShrink < Math.abs(offset)) { if (this.checkPropagate(idx, offset)) { const nextOffset = Math.sign(offset) * (Math.abs(offset) - availableShrink) return availableShrink + this.computeAvailableShrink( offset > 0 ? idx + 2 : idx - 2, nextOffset) } } return Math.min(availableShrink, Math.abs(offset)) } ///////////////////////////////////////////////////////// // Returns flex value for unit pixel // ///////////////////////////////////////////////////////// computePixelFlex (orientation = this.props.orientation) { if (!this.ref.current) { console.warn('Unable to locate ReflexContainer dom node'); return 0.0; } switch (orientation) { case 'horizontal': if (this.ref.current.offsetHeight === 0.0) { console.warn( 'Found ReflexContainer with height=0, ' + 'this will cause invalid behavior...') console.warn(this.ref.current) return 0.0 } return 1.0 / this.ref.current.offsetHeight case 'vertical': default: if (this.ref.current.offsetWidth === 0.0) { console.warn( 'Found ReflexContainer with width=0, ' + 'this will cause invalid behavior...') console.warn(this.ref.current) return 0.0 } return 1.0 / this.ref.current.offsetWidth } } ///////////////////////////////////////////////////////// // Adds offset to a given ReflexElement // ///////////////////////////////////////////////////////// addOffset (element, offset) { const size = this.getSize(element) const idx = element.props.index const newSize = Math.max(size + offset, 0) const currentFlex = this.state.flexData[idx].flex const newFlex = (currentFlex > 0) ? currentFlex * newSize / size : this.computePixelFlex () * newSize this.state.flexData[idx].flex = (!isFinite(newFlex) || isNaN(newFlex)) ? 0 : newFlex } ///////////////////////////////////////////////////////// // Recursively dispatches stretch offset across // children elements starting at splitter idx // ///////////////////////////////////////////////////////// dispatchStretch (idx, offset) { const childIdx = offset < 0 ? idx + 1 : idx - 1 if (childIdx < 0 || childIdx > this.children.length-1) { return [] } const child = this.children[childIdx] const size = this.getSize(child) const newSize = Math.min( child.props.maxSize, size + Math.abs(offset)) const dispatchedStretch = newSize - size this.addOffset(child, dispatchedStretch) if (dispatchedStretch < Math.abs(offset)) { const nextIdx = idx - Math.sign(offset) * 2 const nextOffset = Math.sign(offset) * (Math.abs(offset) - dispatchedStretch) return [ child, ...this.dispatchStretch(nextIdx, nextOffset) ] } return [child] } ///////////////////////////////////////////////////////// // Recursively dispatches shrink offset across // children elements starting at splitter idx // ///////////////////////////////////////////////////////// dispatchShrink (idx, offset) { const childIdx = offset > 0 ? idx + 1 : idx - 1 if (childIdx < 0 || childIdx > this.children.length-1) { return [] } const child = this.children[childIdx] const size = this.getSize(child) const newSize = Math.max( child.props.minSize, size - Math.abs(offset)) const dispatchedShrink = newSize - size this.addOffset(child, dispatchedShrink) if (Math.abs(dispatchedShrink) < Math.abs(offset)) { const nextIdx = idx + Math.sign(offset) * 2 const nextOffset = Math.sign(offset) * (Math.abs(offset) + dispatchedShrink) return [ child, ...this.dispatchShrink(nextIdx, nextOffset) ] } return [child] } ///////////////////////////////////////////////////////// // Dispatch offset at splitter idx // ///////////////////////////////////////////////////////// dispatchOffset (idx, offset) { return [ ...this.dispatchStretch(idx, offset), ...this.dispatchShrink(idx, offset) ] } ///////////////////////////////////////////////////////// // Emits given if event for each given element // if present in the component props // ///////////////////////////////////////////////////////// emitElementsEvent (elements, event) { this.toArray(elements).forEach(component => { if (component.props[event]) { const compRef = component.props.ref || component.ref component.props[event]({ domElement: compRef?.current, component }) } }) } ///////////////////////////////////////////////////////// // Computes initial flex data based on provided flex // properties. By default each ReflexElement gets // evenly arranged within its container // ///////////////////////////////////////////////////////// computeFlexData ( children = this.getValidChildren(), props = this.props) { const pixelFlex = this.computePixelFlex(props.orientation) const computeFreeFlex = (flexData) => { return flexData.reduce((sum, entry) => { if (!ReflexSplitter.isA(entry) && entry.constrained) { return sum - entry.flex } return sum }, 1.0) } const computeFreeElements = (flexData) => { return flexData.reduce((sum, entry) => { if (!ReflexSplitter.isA(entry) && !entry.constrained) { return sum + 1 } return sum }, 0.0) } const flexDataInit = children.map((child) => { const props = child.props return { maxFlex: (props.maxSize || Number.MAX_VALUE) * pixelFlex, sizeFlex: (props.size || Number.MAX_VALUE) * pixelFlex, minFlex: (props.minSize || 1) * pixelFlex, constrained: props.flex !== undefined, flex: props.flex || 0, type: child.type } }) const computeFlexDataRec = (flexDataIn, depth=0) => { let hasContrain = false const freeElements = computeFreeElements(flexDataIn) const freeFlex = computeFreeFlex(flexDataIn) const flexDataOut = flexDataIn.map(entry => { if (ReflexSplitter.isA(entry)) { return entry } const proposedFlex = !entry.constrained ? freeFlex/freeElements : entry.flex const constrainedFlex = Math.min(entry.sizeFlex, Math.min(entry.maxFlex, Math.max(entry.minFlex, proposedFlex))) const constrained = entry.constrained || (constrainedFlex !== proposedFlex) hasContrain = hasContrain || constrained return { ...entry, flex: constrainedFlex, constrained } }) return (hasContrain && depth < this.props.maxRecDepth) ? computeFlexDataRec(flexDataOut, depth+1) : flexDataOut } const flexData = computeFlexDataRec(flexDataInit) return flexData.map(entry => { return { flex: !ReflexSplitter.isA(entry) ? entry.flex : 0.0, ref: React.createRef() } }) } ///////////////////////////////////////////////////////// // Utility method to ensure given argument is // returned as an array // ///////////////////////////////////////////////////////// toArray (obj) { return obj ? (Array.isArray(obj) ? obj : [obj]) : [] } ///////////////////////////////////////////////////////// // Render container. This will clone all original child // components in order to pass some internal properties // used to handle resizing logic // ///////////////////////////////////////////////////////// render () { const className = [ this.state.resizing ? 'reflex-resizing':'', ...this.props.className.split(' '), this.props.orientation, 'reflex-container' ].join(' ').trim() this.children = React.Children.map( this.getValidChildren(), (child, index) => { if (index > this.state.flexData.length - 1) { return <div/> } const flexData = this.state.flexData[index] const newProps = { ...child.props, maxSize: child.props.maxSize || Number.MAX_VALUE, orientation: this.props.orientation, minSize: child.props.minSize || 1, events: this.events, flex: flexData.flex, ref: flexData.ref, index } return React.cloneElement(child, newProps) }) return ( <div {...getDataProps(this.props)} style={this.props.style} className={className} ref={this.ref}> { this.children } </div> ) } }