/* @flow */ import React, {PropTypes} from 'react'; import ReactDOM from 'react-dom'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import TransitionGroup from '../lib/transitions/TransitionGroup'; import classNames from 'classnames'; import Animated from '../lib/animated'; import Flux from '../lib/flux/index'; import ContextMenuStore from '../stores/ContextMenuStore'; import PopoutStore from '../stores/PopoutStore'; import PopoutActionCreators from '../actions/PopoutActionCreators'; import Backdrop from './common/Backdrop'; import '../styles/popout.styl'; // Putting this in the style is sufficiently complex. No cross browser way to compute 'outerHeight' const WINDOW_BOTTOM_MARGIN = 10; // This maps with the CSS for the arrow + margin on popouts const POPOUT_MARGIN = 14; type AnimationOptions = { position: string, x: number, y: number, offsetX: ?number, offsetY: ?number, width: number, height: number, clickPos?: number, }; interface Animation { run(node: HTMLElement, options: AnimationOptions, callback: Function): void, getStyle(options: AnimationOptions): {[key: string]: any}, } function getEndStyle(position) { switch (position) { case 'left': return {x: -1, y: 0}; case 'right': return {x: 0, y: 0}; case 'bottom': return {x: -0.5, y: 0}; case 'bottom-right': return {x: -1, y: 0}; case 'top-right': return {x: -1, y: -1}; case 'top': default: return {x: -0.5, y: -1}; } } class NoneAnimation { animated: Animated.ValueXY; constructor() { this.animated = new Animated.ValueXY({x: 0, y: 0}); } run(node, {position}, callback) { this.animated.setValue(getEndStyle(position)); callback(); } getStyle() { return Animated.accelerate({ transform: [ { translateX: this.animated.x.interpolate({ inputRange: [-1.05, 1.05], outputRange: ['-105%', '105%'], }), }, { translateY: this.animated.y.interpolate({ inputRange: [-1.05, 1.05], outputRange: ['-105%', '105%'], }), }, ], }); } } class DefaultAnimation { animated: Animated.ValueXY; constructor() { this.animated = new Animated.ValueXY({x: 0, y: 0}); } computeStyles(position) { let startStyle; let endStyle; switch (position) { case 'left': startStyle = {x: -1.05, y: 0}; endStyle = {x: -1, y: 0}; break; case 'right': startStyle = {x: 0.05, y: 0}; endStyle = {x: 0, y: 0}; break; case 'bottom': startStyle = {x: -0.5, y: 0.05}; endStyle = {x: -0.5, y: 0}; break; case 'bottom-right': startStyle = {x: -1, y: 0.05}; endStyle = {x: -1, y: 0}; break; case 'top-right': startStyle = {x: -1, y: -1.05}; endStyle = {x: -1, y: -1}; break; case 'top': default: startStyle = {x: -0.5, y: -1.05}; endStyle = {x: -0.5, y: -1}; break; } return { startStyle, endStyle, }; } run(node, {position, x, offsetX}, callback) { const {startStyle, endStyle} = this.computeStyles(position); // Clamp the popout to the horizontal width off the screen const left = x + offsetX + node.offsetWidth * endStyle.x; const right = left + node.offsetWidth; if (left < 0) { const percentageOffset = left / node.offsetWidth; endStyle.x += percentageOffset; startStyle.x += percentageOffset; } else if (right > window.innerWidth) { const percentageOffset = (window.innerWidth - right) / node.offsetWidth; endStyle.x += percentageOffset; startStyle.x += percentageOffset; } this.animated.setValue(startStyle); Animated.timing(this.animated, { toValue: endStyle, duration: 150, easing: Animated.Easing.inOut(Animated.Easing.cubic), }).start(callback); } getStyle() { return Animated.accelerate({ transform: [ { translateX: this.animated.x.interpolate({ inputRange: [-1.05, 1.05], outputRange: ['-105%', '105%'], }), }, { translateY: this.animated.y.interpolate({ inputRange: [-1.05, 1.05], outputRange: ['-105%', '105%'], }), }, ], }); } } class SpringAnimation { animated: Animated.Value; constructor() { this.animated = new Animated.Value(0); } run(node, options, callback) { this.animated.setValue(0); Animated.spring(this.animated, { toValue: 1, tension: 80, friction: 8, overshootClamping: true, }).start(callback); } interpolate(...outputRange: Array) { return this.animated.interpolate({inputRange: [0, 1], outputRange}); } getStyle({position, height, width, x, offsetX, clickPos}) { const endStyle = getEndStyle(position); let dimensions; if (height > 0) { dimensions = { height: this.interpolate(0, height), width: this.interpolate(0, width), left: this.interpolate(x + clickPos, x + offsetX), }; } return Animated.accelerate({ ...dimensions, overflow: 'hidden', transform: [{translateY: `${endStyle.y * 100}%`}, {translateX: `${endStyle.x * 100}%`}], }); } } const Popout = React.createClass({ mixins: [PureRenderMixin], propTypes: { popoutKey: PropTypes.string.isRequired, render: PropTypes.func.isRequired, position: PropTypes.oneOf(['left', 'top', 'right', 'bottom', 'top-right', 'bottom-right']).isRequired, animationType: PropTypes.oneOf(['default', 'spring', 'none']).isRequired, x: PropTypes.number.isRequired, y: PropTypes.number.isRequired, targetWidth: PropTypes.number.isRequired, targetHeight: PropTypes.number.isRequired, closeOnScroll: PropTypes.bool, shadow: PropTypes.bool, text: PropTypes.string, containerClass: PropTypes.string, preventInvert: PropTypes.bool, zIndexBoost: PropTypes.number, clickPos: PropTypes.number, }, getDefaultProps() { return { animationType: 'default', closeOnScroll: true, preventInvert: false, shadow: true, text: '', }; }, getInitialState(): { offsetX: ?number, offsetY: ?number, width: number, height: number, position: string, animation: Animation, invert: boolean, } { let animation; switch (this.props.animationType) { case 'default': animation = new DefaultAnimation(); break; case 'spring': animation = new SpringAnimation(); break; case 'none': default: animation = new NoneAnimation(); } return { width: 0, height: 0, offsetX: null, offsetY: null, animation, position: '', invert: false, }; }, preventNextClose: false, componentWillEnterCallback: () => {}, componentDidMount() { document.addEventListener('click', this.close, true); document.addEventListener('contextmenu', this.closeContext, true); this.updateOffsets(); }, componentWillEnter(callback) { this.componentWillEnterCallback = callback; }, _componentWillEnter() { if (this.componentWillEnterCallback) { const callback = this.componentWillEnterCallback; delete this.componentWillEnterCallback; const node = ReactDOM.findDOMNode(this); if (!(node instanceof HTMLElement)) { throw new Error('node is not an instance of HTMLElement'); } this.state.animation.run( node, { position: this.state.position, x: this.props.x, y: this.props.y, width: this.state.width, height: this.state.height, targetWidth: this.props.targetWidth, targetHeight: this.props.targetHeight, offsetX: this.state.offsetX, offsetY: this.state.offsetY, }, callback ); } }, componentWillLeave(callback) { callback(); }, componentDidUpdate({text, position, x, y, targetWidth, targetHeight, closeOnScroll}) { if ( this.props.text !== text || this.props.position !== position || this.props.x !== x || this.props.y !== y || this.props.targetWidth !== targetWidth || this.props.targetHeight !== targetHeight || this.props.closeOnScroll !== closeOnScroll ) { this.updateOffsets(); } }, componentWillUpdate(nextProps) { if (nextProps.needsRerender) { PopoutActionCreators.didRerender(this.props.popoutKey); } }, componentWillUnmount() { document.removeEventListener('click', this.close, true); document.removeEventListener('contextmenu', this.closeContext, true); }, close(e: Event) { if (this.preventNextClose && ContextMenuStore.isOpen()) { this.preventNextClose = false; return; } if (e != null) { let target = e.target; const myElement = ReactDOM.findDOMNode(this); while (target instanceof Element) { if (target === myElement) return; if (target.classList != null && target.classList.contains('popout-open')) return; target = target.parentNode; } } PopoutActionCreators.close(this.props.popoutKey); }, closeContext(e: Event) { if (e != null) { let target = e.target; while (target instanceof HTMLElement) { if (target === ReactDOM.findDOMNode(this)) { this.preventNextClose = true; return; } target = target instanceof HTMLElement ? target.parentNode : null; } } if (this.preventNextClose && ContextMenuStore.isOpen()) { this.preventNextClose = false; return; } PopoutActionCreators.close(this.props.popoutKey); }, updateOffsets() { const {preventInvert} = this.props; let {position} = this.props; const domNode = ReactDOM.findDOMNode(this); if (!(domNode instanceof HTMLElement)) { throw new Error('domNode is not instance of HTMLElement'); } const width = domNode.offsetWidth; const height = domNode.offsetHeight; const isTopside = position === 'top' || position === 'top-right'; let invert = false; if (!preventInvert) { if (isTopside) { // only invert from top if the target area is in the top half of the window area invert = this.props.y < window.innerHeight * 0.5 && this.props.y - domNode.offsetHeight < 0; } else { invert = this.props.y + this.props.targetHeight + POPOUT_MARGIN + height >= window.innerHeight; } if (invert) { if (position === 'bottom') { position = 'top'; } else if (position === 'top-right') { position = 'bottom-right'; } else if (position === 'top') { position = 'bottom'; } } } const newState = { invert, position, height, width, offsetX: 0, offsetY: 0, }; switch (position) { case 'left': newState.offsetX = 0; newState.offsetY = this.props.targetHeight / 2; break; case 'right': newState.offsetX = this.props.targetWidth; newState.offsetY = this.props.targetHeight / 2; break; case 'bottom': newState.offsetX = this.props.targetWidth / 2; newState.offsetY = this.props.targetHeight; break; case 'bottom-right': newState.offsetX = this.props.targetWidth; newState.offsetY = this.props.targetHeight; break; case 'top-right': newState.offsetX = this.props.targetWidth; newState.offsetY = 0; break; case 'top': default: newState.offsetX = this.props.targetWidth / 2; newState.offsetY = 0; } if (invert && (this.props.position === 'right' || this.props.position === 'left')) { if (this.props.closeOnScroll) { // align to the item's y position newState.offsetY -= height - this.props.targetHeight; } else { // clamp to end at the bottom of the window // FLOWJS Note: Flow thinks height is a string, todo: remove string2int newState.offsetY -= this.props.y + +height - window.innerHeight + WINDOW_BOTTOM_MARGIN; } } this.setState(newState, this._componentWillEnter); }, render() { const {offsetX, offsetY, invert, position, animation} = this.state; const {zIndexBoost, closeOnScroll, x, y, popoutKey, animationType, clickPos, render, containerClass} = this.props; const defaultStyle = { left: offsetX === null ? null : x + offsetX, top: offsetY == null ? null : y + offsetY, zIndex: zIndexBoost != null ? 1000 + zIndexBoost : null, }; const style = { ...defaultStyle, ...animation.getStyle({ position, width: this.state.width, height: this.state.height, x, y, offsetX, offsetY, clickPos, }), }; if (!style.visibility) { style.visibility = style.transform == null ? 'hidden' : 'visible'; } const className = { popout: true, 'popout-invert': invert && closeOnScroll, [`popout-${position}`]: true, 'no-arrow': !closeOnScroll, 'no-shadow': !this.props.shadow || animationType === 'none', }; if (containerClass) { className[containerClass] = true; } return ( {render({popoutKey, onClose: this.close})} ); }, }); const Popouts = React.createClass({ mixins: [Flux.LazyStoreListenerMixin(PopoutStore)], getInitialState() { return this.getStateFromStores(); }, getStateFromStores() { return { popouts: PopoutStore.getPopouts(), }; }, render() { const hasBackdrop = this.state.popouts.some(p => p.backdrop); const backdrop = hasBackdrop ? : null; return ( {backdrop} {this.state.popouts.map(popoutData => )} ); }, }); export default Popouts; // WEBPACK FOOTER // // ./discord_app/components/Popouts.js