diff --git a/package.json b/package.json index f9ff7df03f..2a17446444 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "react-redux": "5.0.6", "redux": "^3.6.0", "redux-thunk": "2.2.0", - "reselect": "3.0.1" + "reselect": "3.0.1", + "uuid": "^3.1.0" }, "devDependencies": { "@atlaskit/css-reset": "1.1.5", diff --git a/src/view/animation.js b/src/view/animation.js index 5d05640960..6a9d50937f 100644 --- a/src/view/animation.js +++ b/src/view/animation.js @@ -23,6 +23,11 @@ export const physics = (() => { return { standard, fast }; })(); +const transitionCurve = 'cubic-bezier(0.2, 0, 0, 1)'; +const transitionTime = '0.2s'; + export const css = { - outOfTheWay: 'transform 0.2s cubic-bezier(0.2, 0, 0, 1)', + outOfTheWay: `transform ${transitionTime} ${transitionCurve}`, + transitionCurve, + transitionTime, }; diff --git a/src/view/draggable/draggable.jsx b/src/view/draggable/draggable.jsx index 24f8a37caf..93e939a1aa 100644 --- a/src/view/draggable/draggable.jsx +++ b/src/view/draggable/draggable.jsx @@ -20,7 +20,7 @@ import type { Provided as DragHandleProvided, } from '../drag-handle/drag-handle-types'; import getCenterPosition from '../get-center-position'; -import Placeholder from '../placeholder'; +import { StaticPlaceholder as Placeholder } from '../placeholder'; import { droppableIdKey } from '../context-keys'; import { add } from '../../state/position'; import type { diff --git a/src/view/droppable/connected-droppable.js b/src/view/droppable/connected-droppable.js index 23c84990f6..3c54cfe111 100644 --- a/src/view/droppable/connected-droppable.js +++ b/src/view/droppable/connected-droppable.js @@ -79,9 +79,10 @@ export const makeSelector = (): Selector => { ); const getMapProps = memoizeOne( - (isDraggingOver: boolean, placeholder: ?Placeholder): MapProps => ({ + (isDraggingOver: boolean, placeholder: ?Placeholder, phase: ?string): MapProps => ({ isDraggingOver, placeholder, + phase, }) ); @@ -107,7 +108,7 @@ export const makeSelector = (): Selector => { if (phase === 'DRAGGING') { if (!drag) { console.error('cannot determine dragging over as there is not drag'); - return getMapProps(false, null); + return getMapProps(false, null, phase); } const isDraggingOver = getIsDraggingOver(id, drag.impact.destination); @@ -117,13 +118,13 @@ export const makeSelector = (): Selector => { drag.impact.destination, draggable ); - return getMapProps(isDraggingOver, placeholder); + return getMapProps(isDraggingOver, placeholder, phase); } if (phase === 'DROP_ANIMATING') { if (!pending) { console.error('cannot determine dragging over as there is no pending result'); - return getMapProps(false, null); + return getMapProps(false, null, phase); } const isDraggingOver = getIsDraggingOver(id, pending.impact.destination); @@ -133,10 +134,10 @@ export const makeSelector = (): Selector => { pending.result.destination, draggable ); - return getMapProps(isDraggingOver, placeholder); + return getMapProps(isDraggingOver, placeholder, phase); } - return getMapProps(false, null); + return getMapProps(false, null, phase); }, ); }; diff --git a/src/view/droppable/droppable-types.js b/src/view/droppable/droppable-types.js index 1015ce4b51..7d3c067d81 100644 --- a/src/view/droppable/droppable-types.js +++ b/src/view/droppable/droppable-types.js @@ -31,6 +31,8 @@ export type MapProps = {| // not the user is dragging over a list that // is not the source list placeholder: ?Placeholder, + // whether the entire application is currently in the dragging state + phase: ?string, |} export type OwnProps = {| diff --git a/src/view/droppable/droppable.jsx b/src/view/droppable/droppable.jsx index 95bf4cbf9e..935c17fcae 100644 --- a/src/view/droppable/droppable.jsx +++ b/src/view/droppable/droppable.jsx @@ -14,6 +14,8 @@ type Context = {| [droppableIdKey]: DroppableId |} +const defaultPlaceholderDimensions = { height: 0, width: 0 }; + export default class Droppable extends Component { /* eslint-disable react/sort-comp */ props: Props @@ -62,14 +64,16 @@ export default class Droppable extends Component { } getPlaceholder() { - if (!this.props.placeholder) { - return null; - } + const { phase } = this.props; + const isDraggedOver = Boolean(this.props.placeholder); + const { height, width } = this.props.placeholder || defaultPlaceholderDimensions; return ( ); } diff --git a/src/view/placeholder/StaticPlaceholder.jsx b/src/view/placeholder/StaticPlaceholder.jsx new file mode 100644 index 0000000000..4b648a4985 --- /dev/null +++ b/src/view/placeholder/StaticPlaceholder.jsx @@ -0,0 +1,21 @@ +// @flow +import React, { PureComponent } from 'react'; + +type Props = {| + height: number, + width: number, +|}; + +export default class StaticPlaceholder extends PureComponent { + props: Props + + render() { + const { height, width } = this.props; + const style = { + height, + pointerEvents: 'none', + width, + }; + return
; + } +} diff --git a/src/view/placeholder/index.js b/src/view/placeholder/index.js index 7d723e8f35..9518992c70 100644 --- a/src/view/placeholder/index.js +++ b/src/view/placeholder/index.js @@ -1 +1,2 @@ -export default from './placeholder'; +export default from './Placeholder'; +export { default as StaticPlaceholder } from './StaticPlaceholder'; diff --git a/src/view/placeholder/placeholder.jsx b/src/view/placeholder/placeholder.jsx index de44f9c1b9..11ae72ea82 100644 --- a/src/view/placeholder/placeholder.jsx +++ b/src/view/placeholder/placeholder.jsx @@ -1,20 +1,192 @@ // @flow import React, { PureComponent } from 'react'; +import v4 from 'uuid/v4'; +import { css as transitionStyles } from '../animation'; +import type { Phase } from '../../types'; + +type AnimatedProperties = {| + /** The height of the placeholder */ + height: number, + /** The width of the placeholder */ + width: number, +|}; + +type Props = {| + ...AnimatedProperties, + /** Whether the parent droppable is being dragged over */ + isDraggedOver: boolean, + /** The phase of the drag */ + phase: Phase, +|}; + +type State = {| + ...AnimatedProperties, + /** Whether the placeholder should be animating in */ + isAnimatingIn: boolean, + /** Whether the placeholder should be animating out */ + isAnimatingOut: boolean, +|} + +const VENDOR_PREFIXES: string[] = ['-ms-', '-moz-', '-webkit-', '']; +// We inject a stylesheet into the document so that we can dynamically +// add CSS animations. +const stylesheet: HTMLStyleElement = document.createElement('style'); +stylesheet.type = 'text/css'; +document.getElementsByTagName('head')[0].appendChild(stylesheet); +// A global counter keeping track of how many style rules have been +// added to the stylesheet. +let globalRuleIndex = 0; export default class Placeholder extends PureComponent { - props: {| - height: number, - width: number, - |} + // eslint-disable-next-line react/sort-comp + props: Props - render() { - const style = { - width: this.props.width, - height: this.props.height, + static defaultProps = { + isDraggedOver: false, + } + + state: State = { + height: this.props.height, + isAnimatingIn: false, + isAnimatingOut: false, + width: this.props.width, + } + + // Every time we instantiate a new placeholder we record the index + // of its animation rules, then increment the global counter + ruleIndex: number = (() => { + const ruleIndex = globalRuleIndex; + globalRuleIndex += VENDOR_PREFIXES.length; + return ruleIndex; + })() + + componentWillReceiveProps(newProps: Props) { + if (newProps.phase === this.props.phase && + newProps.isDraggedOver === this.props.isDraggedOver) { + return; + } + + if (newProps.phase === 'DRAGGING') { + const height = newProps.height || this.state.height; + const width = newProps.width || this.state.width; + const isAnimatingIn = newProps.isDraggedOver; + const isAnimatingOut = !newProps.isDraggedOver; + this.setState({ height, isAnimatingIn, isAnimatingOut, width }); + } + + // Reset the state once the drop completes + if (newProps.phase === 'DROP_COMPLETE') { + this.setState({ + height: 0, + isAnimatingIn: false, + isAnimatingOut: false, + width: 0, + }); + } + } + + getAnimationKeyframes = (animationName: string): string[] => { + const { height, isAnimatingIn, isAnimatingOut, width } = this.state; + const expanded = `height: ${height}px; width: ${width}px;`; + const collapsed = 'height: 0; width: 0;'; + const keyframeSteps = (() => { + if (isAnimatingIn) { + return `from { ${collapsed} } to { ${expanded} }`; + } + if (isAnimatingOut) { + return `from { ${expanded} } to { ${collapsed} }`; + } + return ''; + })(); + + return VENDOR_PREFIXES.map( + prefix => `@${prefix}keyframes ${animationName} { ${keyframeSteps} }` + ); + } + + createAnimation = (): ?string => { + const { sheet } = stylesheet; + + // Stop flow complaining about possibly undefined properties + if (!sheet || + !sheet.cssRules || + !sheet.insertRule || + !sheet.deleteRule) { + return null; + } + + // We need to generate a random name for the animation every time + // because you can't dynamically update CSS animations + const animationName = `rbdnd-placeholder-animation-${v4()}`; + const keyframes = this.getAnimationKeyframes(animationName); + // It's easier to manage if we inject a dummy rule when the + // browser doesn't support a prefixed version + const dummyRule = '.rbdnd-placeholder-donotuse { display: initial; }'; + const { ruleIndex } = this; + + for (let i = 0; i < VENDOR_PREFIXES.length; i++) { + const thisRuleIndex = ruleIndex + i; + // `sheet` is a CSSStyleSheet but the definition of HTMLStyleElement indicates that it + // contains a StyleSheet which doesn't have all the methods of its child CSSStyleSheet + // $ExpectError - property cssRules not found in StyleSheet + if (sheet.cssRules[thisRuleIndex]) { + // $ExpectError - property deleteRule not found in StyleSheet + sheet.deleteRule(thisRuleIndex); + } + // The browser will throw if it doesn't support a prefixed version of the rule + try { + // $ExpectError - property insertRule not found in StyleSheet + sheet.insertRule(keyframes[i], thisRuleIndex); + } catch (err) { + // If it doesn't like it we inject a dummy rule instead + // $ExpectError - property insertRule not found in StyleSheet + sheet.insertRule(dummyRule, thisRuleIndex); + } + } + + return animationName; + } + + getPlaceholderStyle = () => { + const { isDraggedOver, phase } = this.props; + const { height, width } = this.state; + + const animationName = this.createAnimation(); + + const staticStyles = { + height, pointerEvents: 'none', + width, + }; + + // Hold the full height during a drop + if (phase === 'DROP_ANIMATING' && isDraggedOver) { + return staticStyles; + } + + // Animate in/out during a drag + if (phase === 'DRAGGING') { + return { + ...staticStyles, + animationName, + animationDuration: transitionStyles.transitionTime, + animationTimingFunction: transitionStyles.transitionCurve, + animationDelay: '0.0s', + animationIterationCount: 1, + animationDirection: 'normal', + animationFillMode: 'forwards', + }; + } + + // Otherwise don't display + return { + display: 'none', }; + } + + render() { return ( -
+
); } }