import type { Position } from 'css-box-model';
import memoizeOne from 'memoize-one';
import type { FunctionComponent } from 'react';
import { connect } from 'react-redux';
import Draggable from './draggable';
import isDragging from '../../state/is-dragging';
import { origin, negate } from '../../state/position';
import isStrictEqual from '../is-strict-equal';
import * as animation from '../../animation';
import { dropAnimationFinished as dropAnimationFinishedAction } from '../../state/action-creators';
import type {
State,
DraggableId,
DroppableId,
DraggableDimension,
Displacement,
CompletedDrag,
DragImpact,
MovementMode,
DropResult,
LiftEffect,
Combine,
} from '../../types';
import type {
DraggableProps,
MapProps,
OwnProps,
DispatchProps,
Selector,
DraggableStateSnapshot,
DropAnimation,
} from './draggable-types';
import whatIsDraggedOver from '../../state/droppable/what-is-dragged-over';
import StoreContext from '../context/store-context';
import whatIsDraggedOverFromResult from '../../state/droppable/what-is-dragged-over-from-result';
import { tryGetCombine } from '../../state/get-impact-location';
const getCombineWithFromResult = (result: DropResult): DraggableId | null => {
return result.combine ? result.combine.draggableId : null;
};
const getCombineWithFromImpact = (impact: DragImpact): DraggableId | null => {
return impact.at && impact.at.type === 'COMBINE'
? impact.at.combine.draggableId
: null;
};
type TrySelect = (state: State, ownProps: OwnProps) => MapProps | null;
function getDraggableSelector(): TrySelect {
const memoizedOffset = memoizeOne(
(x: number, y: number): Position => ({
x,
y,
}),
);
const getMemoizedSnapshot = memoizeOne(
(
mode: MovementMode,
isClone: boolean,
draggingOver: DroppableId | null = null,
combineWith: DraggableId | null = null,
dropping: DropAnimation | null = null,
): DraggableStateSnapshot => ({
isDragging: true,
isClone,
isDropAnimating: Boolean(dropping),
dropAnimation: dropping,
mode,
draggingOver,
combineWith,
combineTargetFor: null,
}),
);
const getMemoizedProps = memoizeOne(
(
offset: Position,
mode: MovementMode,
dimension: DraggableDimension,
isClone: boolean,
// the id of the droppable you are over
draggingOver: DroppableId | null = null,
// the id of a draggable you are grouping with
combineWith: DraggableId | null = null,
forceShouldAnimate: boolean | null = null,
): MapProps => ({
mapped: {
type: 'DRAGGING',
dropping: null,
draggingOver,
combineWith,
mode,
offset,
dimension,
forceShouldAnimate,
snapshot: getMemoizedSnapshot(
mode,
isClone,
draggingOver,
combineWith,
null,
),
},
}),
);
const selector: TrySelect = (
state: State,
ownProps: OwnProps,
): MapProps | null => {
// Dragging
if (isDragging(state)) {
// not the dragging item
if (state.critical.draggable.id !== ownProps.draggableId) {
return null;
}
const offset: Position = state.current.client.offset;
const dimension: DraggableDimension =
state.dimensions.draggables[ownProps.draggableId];
// const shouldAnimateDragMovement: boolean = state.shouldAnimate;
const draggingOver: DroppableId | null = whatIsDraggedOver(state.impact);
const combineWith: DraggableId | null = getCombineWithFromImpact(
state.impact,
);
const forceShouldAnimate: boolean | null = state.forceShouldAnimate;
return getMemoizedProps(
memoizedOffset(offset.x, offset.y),
state.movementMode,
dimension,
ownProps.isClone,
draggingOver,
combineWith,
forceShouldAnimate,
);
}
// Dropping
if (state.phase === 'DROP_ANIMATING') {
const completed: CompletedDrag = state.completed;
if (completed.result.draggableId !== ownProps.draggableId) {
return null;
}
const isClone: boolean = ownProps.isClone;
const dimension: DraggableDimension =
state.dimensions.draggables[ownProps.draggableId];
const result: DropResult = completed.result;
const mode: MovementMode = result.mode;
// these need to be pulled from the result as they can be different to the final impact
const draggingOver: DroppableId | null =
whatIsDraggedOverFromResult(result);
const combineWith: DraggableId | null = getCombineWithFromResult(result);
const duration: number = state.dropDuration;
// not memoized as it is the only execution
const dropping: DropAnimation = {
duration,
curve: animation.curves.drop,
moveTo: state.newHomeClientOffset,
opacity: combineWith ? animation.combine.opacity.drop : null,
scale: combineWith ? animation.combine.scale.drop : null,
};
return {
mapped: {
type: 'DRAGGING',
offset: state.newHomeClientOffset,
dimension,
dropping,
draggingOver,
combineWith,
mode,
forceShouldAnimate: null,
snapshot: getMemoizedSnapshot(
mode,
isClone,
draggingOver,
combineWith,
dropping,
),
},
};
}
return null;
};
return selector;
}
function getSecondarySnapshot(
combineTargetFor: DraggableId | null = null,
): DraggableStateSnapshot {
return {
isDragging: false,
isDropAnimating: false,
isClone: false,
dropAnimation: null,
mode: null,
draggingOver: null,
combineTargetFor,
combineWith: null,
};
}
const atRest: MapProps = {
mapped: {
type: 'SECONDARY',
offset: origin,
combineTargetFor: null,
shouldAnimateDisplacement: true,
snapshot: getSecondarySnapshot(null),
},
};
function getSecondarySelector(): TrySelect {
const memoizedOffset = memoizeOne(
(x: number, y: number): Position => ({
x,
y,
}),
);
const getMemoizedSnapshot = memoizeOne(getSecondarySnapshot);
const getMemoizedProps = memoizeOne(
(
offset: Position,
// eslint-disable-next-line default-param-last
combineTargetFor: DraggableId | null = null,
shouldAnimateDisplacement: boolean,
): MapProps => ({
mapped: {
type: 'SECONDARY',
offset,
combineTargetFor,
shouldAnimateDisplacement,
snapshot: getMemoizedSnapshot(combineTargetFor),
},
}),
);
// Is we are the combine target for something then we need to publish that
// otherwise we will return null to get the default props
const getFallback = (
combineTargetFor?: DraggableId | null,
): MapProps | null => {
return combineTargetFor
? getMemoizedProps(origin, combineTargetFor, true)
: null;
};
const getProps = (
ownId: DraggableId,
draggingId: DraggableId,
impact: DragImpact,
afterCritical: LiftEffect,
): MapProps | null => {
const visualDisplacement: Displacement | null =
impact.displaced.visible[ownId];
const isAfterCriticalInVirtualList = Boolean(
afterCritical.inVirtualList && afterCritical.effected[ownId],
);
const combine: Combine | null = tryGetCombine(impact);
const combineTargetFor: DraggableId | null =
combine && combine.draggableId === ownId ? draggingId : null;
if (!visualDisplacement) {
if (!isAfterCriticalInVirtualList) {
return getFallback(combineTargetFor);
}
// After critical but not visibly displaced in a virtual list
// This can occur if:
// 1. the item is not visible (displaced.invisible)
// 2. We have moved out of the home list.
// Don't need to do anything - item is invisible
if (impact.displaced.invisible[ownId]) {
return null;
}
// We are no longer over the home list.
// We need to move backwards to close the gap that the dragging item has left
const change: Position = negate(afterCritical.displacedBy.point);
const offset: Position = memoizedOffset(change.x, change.y);
return getMemoizedProps(offset, combineTargetFor, true);
}
if (isAfterCriticalInVirtualList) {
// In a virtual list the removal of a dragging item does
// not cause the list to collapse. So when something is 'displaced'
// we can just leave it in the original spot.
return getFallback(combineTargetFor);
}
const displaceBy: Position = impact.displacedBy.point;
const offset: Position = memoizedOffset(displaceBy.x, displaceBy.y);
return getMemoizedProps(
offset,
combineTargetFor,
visualDisplacement.shouldAnimate,
);
};
const selector: TrySelect = (
state: State,
ownProps: OwnProps,
): MapProps | null => {
// Dragging
if (isDragging(state)) {
// we do not care about the dragging item
if (state.critical.draggable.id === ownProps.draggableId) {
return null;
}
return getProps(
ownProps.draggableId,
state.critical.draggable.id,
state.impact,
state.afterCritical,
);
}
// Dropping
if (state.phase === 'DROP_ANIMATING') {
const completed: CompletedDrag = state.completed;
// do nothing if this was the dragging item
if (completed.result.draggableId === ownProps.draggableId) {
return null;
}
return getProps(
ownProps.draggableId,
completed.result.draggableId,
completed.impact,
completed.afterCritical,
);
}
// Otherwise
return null;
};
return selector;
}
// Returning a function to ensure each
// Draggable gets its own selector
export const makeMapStateToProps = (): Selector => {
const draggingSelector: TrySelect = getDraggableSelector();
const secondarySelector: TrySelect = getSecondarySelector();
const selector = (state: State, ownProps: OwnProps): MapProps =>
draggingSelector(state, ownProps) ||
secondarySelector(state, ownProps) ||
atRest;
return selector;
};
const mapDispatchToProps: DispatchProps = {
dropAnimationFinished: dropAnimationFinishedAction,
};
// Leaning heavily on the default shallow equality checking
// that `connect` provides.
// It avoids needing to do it own within ``
const ConnectedDraggable = connect(
// returning a function so each component can do its own memoization
makeMapStateToProps,
mapDispatchToProps,
// mergeProps: use default
null as any,
// options
{
// Using our own context for the store to avoid clashing with consumers
context: StoreContext as any,
// Default value: shallowEqual
// Switching to a strictEqual as we return a memoized object on changes
areStatePropsEqual: isStrictEqual,
},
// FIXME: Typings are really complexe
)(Draggable) as unknown as FunctionComponent;
export default ConnectedDraggable;