2017-06-08_509bba0/509bba0_unpacked_with_node_.../discord_app/components/Popouts.js

550 lines
14 KiB
JavaScript
Executable File

/* @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<number>) {
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 (
<Animated.div className={classNames(className)} style={style}>
{render({popoutKey, onClose: this.close})}
</Animated.div>
);
},
});
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 ? <Backdrop backdropStyle={Backdrop.SUBTLE} /> : null;
return (
<TransitionGroup className={`theme-${this.props.theme}`} component="div">
{backdrop}
{this.state.popouts.map(popoutData =>
<Popout popoutKey={String(popoutData.key)} {...popoutData} key={`${popoutData.key}-popout`} />
)}
</TransitionGroup>
);
},
});
export default Popouts;
// WEBPACK FOOTER //
// ./discord_app/components/Popouts.js