550 lines
14 KiB
JavaScript
Executable file
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
|