react-beautiful-dnd
Version:
Beautiful, accessible drag and drop for lists with React.js
570 lines (476 loc) • 14.5 kB
Flow
// @flow
import memoizeOne from 'memoize-one';
import type {
Action,
State,
DraggableDimension,
DroppableDimension,
DroppableId,
DraggableId,
DimensionState,
DraggableDescriptor,
DraggableDimensionMap,
DroppableDimensionMap,
DragState,
DropResult,
CurrentDrag,
DragImpact,
InitialDrag,
PendingDrop,
Phase,
DraggableLocation,
CurrentDragPositions,
Position,
InitialDragPositions,
} from '../types';
import { add, subtract } from './position';
import { noMovement } from './no-impact';
import getDragImpact from './get-drag-impact/';
import moveToNextIndex from './move-to-next-index/';
import type { Result as MoveToNextResult } from './move-to-next-index/move-to-next-index-types';
import type { Result as MoveCrossAxisResult } from './move-cross-axis/move-cross-axis-types';
import moveCrossAxis from './move-cross-axis/';
import { scrollDroppable } from './dimension';
const noDimensions: DimensionState = {
request: null,
draggable: {},
droppable: {},
};
const origin: Position = { x: 0, y: 0 };
const clean = memoizeOne((phase?: Phase = 'IDLE'): State => ({
phase,
drag: null,
drop: null,
dimension: noDimensions,
}));
type MoveArgs = {|
state: State,
clientSelection: Position,
shouldAnimate: boolean,
windowScroll ?: Position,
// force a custom drag impact
impact?: DragImpact,
|}
const canPublishDimension = (phase: Phase): boolean =>
['IDLE', 'DROP_ANIMATING', 'DROP_COMPLETE'].indexOf(phase) === -1;
// TODO: move into own file and write tests
const move = ({
state,
clientSelection,
shouldAnimate,
windowScroll,
impact,
}: MoveArgs): State => {
if (state.phase !== 'DRAGGING') {
console.error('cannot move while not dragging');
return clean();
}
const last: ?DragState = state.drag;
if (last == null) {
console.error('cannot move if there is no drag information');
return clean();
}
const previous: CurrentDrag = last.current;
const initial: InitialDrag = last.initial;
const currentWindowScroll: Position = windowScroll || previous.windowScroll;
const client: CurrentDragPositions = (() => {
const offset: Position = subtract(clientSelection, initial.client.selection);
const result: CurrentDragPositions = {
offset,
selection: clientSelection,
center: add(offset, initial.client.center),
};
return result;
})();
const page: CurrentDragPositions = {
selection: add(client.selection, currentWindowScroll),
offset: add(client.offset, currentWindowScroll),
center: add(client.center, currentWindowScroll),
};
const current: CurrentDrag = {
client,
page,
shouldAnimate,
windowScroll: currentWindowScroll,
};
const newImpact: DragImpact = (impact || getDragImpact({
pageCenter: page.center,
draggable: state.dimension.draggable[initial.descriptor.id],
draggables: state.dimension.draggable,
droppables: state.dimension.droppable,
previousImpact: last.impact,
}));
const drag: DragState = {
initial,
impact: newImpact,
current,
};
return {
...state,
drag,
};
};
const updateStateAfterDimensionChange = (newState: State): State => {
// not dragging yet
if (newState.phase === 'COLLECTING_INITIAL_DIMENSIONS') {
return newState;
}
// not calculating movement if not in the DRAGGING phase
if (newState.phase !== 'DRAGGING') {
return newState;
}
// already dragging - need to recalculate impact
if (!newState.drag) {
console.error('cannot update a draggable dimension in an existing drag as there is invalid drag state');
return clean();
}
return move({
state: newState,
// use the existing values
clientSelection: newState.drag.current.client.selection,
shouldAnimate: newState.drag.current.shouldAnimate,
});
};
export default (state: State = clean('IDLE'), action: Action): State => {
if (action.type === 'CLEAN') {
return clean();
}
if (action.type === 'PREPARE') {
return clean('PREPARING');
}
if (action.type === 'REQUEST_DIMENSIONS') {
if (state.phase !== 'PREPARING') {
console.error('trying to start a lift while not preparing for a lift');
return clean();
}
const id: DraggableId = action.payload;
return {
phase: 'COLLECTING_INITIAL_DIMENSIONS',
drag: null,
drop: null,
dimension: {
request: id,
draggable: {},
droppable: {},
},
};
}
if (action.type === 'PUBLISH_DRAGGABLE_DIMENSIONS') {
const dimensions: DraggableDimension[] = action.payload;
if (!canPublishDimension(state.phase)) {
console.warn('dimensions rejected as no longer allowing dimension capture in phase', state.phase);
return state;
}
const additions: DraggableDimensionMap = dimensions.reduce((previous, current) => {
previous[current.descriptor.id] = current;
return previous;
}, {});
const newState: State = {
...state,
dimension: {
request: state.dimension.request,
droppable: state.dimension.droppable,
draggable: {
...state.dimension.draggable,
...additions,
},
},
};
return updateStateAfterDimensionChange(newState);
}
if (action.type === 'PUBLISH_DROPPABLE_DIMENSIONS') {
const dimensions: DroppableDimension[] = action.payload;
if (!canPublishDimension(state.phase)) {
console.warn('dimensions rejected as no longer allowing dimension capture in phase', state.phase);
return state;
}
const additions: DroppableDimensionMap = dimensions.reduce((previous, current) => {
previous[current.descriptor.id] = current;
return previous;
}, {});
const newState: State = {
...state,
dimension: {
request: state.dimension.request,
draggable: state.dimension.draggable,
droppable: {
...state.dimension.droppable,
...additions,
},
},
};
return updateStateAfterDimensionChange(newState);
}
if (action.type === 'COMPLETE_LIFT') {
if (state.phase !== 'COLLECTING_INITIAL_DIMENSIONS') {
console.error('trying complete lift without collecting dimensions');
return state;
}
const { id, client, windowScroll, isScrollAllowed } = action.payload;
const page: InitialDragPositions = {
selection: add(client.selection, windowScroll),
center: add(client.center, windowScroll),
};
const draggable: ?DraggableDimension = state.dimension.draggable[id];
if (!draggable) {
console.error('could not find draggable in store after lift');
return clean();
}
const descriptor: DraggableDescriptor = draggable.descriptor;
const initial: InitialDrag = {
descriptor,
isScrollAllowed,
client,
page,
windowScroll,
};
const current: CurrentDrag = {
client: {
selection: client.selection,
center: client.center,
offset: origin,
},
page: {
selection: page.selection,
center: page.center,
offset: origin,
},
windowScroll,
shouldAnimate: false,
};
// Calculate initial impact
const home: ?DroppableDimension = state.dimension.droppable[descriptor.droppableId];
if (!home) {
console.error('Cannot find home dimension for initial lift');
return clean();
}
const destination: DraggableLocation = {
index: descriptor.index,
droppableId: descriptor.droppableId,
};
const impact: DragImpact = {
movement: noMovement,
direction: home.axis.direction,
destination,
};
return {
...state,
phase: 'DRAGGING',
drag: {
initial,
current,
impact,
},
};
}
if (action.type === 'UPDATE_DROPPABLE_DIMENSION_SCROLL') {
if (state.phase !== 'DRAGGING') {
console.error('cannot update a droppable dimensions scroll when not dragging');
return clean();
}
if (state.drag == null) {
console.error('invalid store state');
return clean();
}
// Currently not supporting container scrolling while dragging with a keyboard
// We do not store whether we are dragging with a keyboard in the state but this flag
// does this trick. Ideally this check would not exist.
// Kill the drag instantly
if (!state.drag.initial.isScrollAllowed) {
return clean();
}
const { id, offset } = action.payload;
const target: ?DroppableDimension = state.dimension.droppable[id];
if (!target) {
console.warn('cannot update scroll for droppable as it has not yet been collected');
return state;
}
const dimension: DroppableDimension = scrollDroppable(target, offset);
const newState: State = {
...state,
dimension: {
request: state.dimension.request,
draggable: state.dimension.draggable,
droppable: {
...state.dimension.droppable,
[id]: dimension,
},
},
};
return updateStateAfterDimensionChange(newState);
}
if (action.type === 'UPDATE_DROPPABLE_DIMENSION_IS_ENABLED') {
if (!Object.keys(state.dimension.droppable).length) {
return state;
}
const { id, isEnabled } = action.payload;
const target = state.dimension.droppable[id];
if (!target) {
console.warn('cannot update enabled state for droppable as it has not yet been collected');
return state;
}
if (target.isEnabled === isEnabled) {
console.warn(`trying to set droppable isEnabled to ${String(isEnabled)} but it is already ${String(isEnabled)}`);
return state;
}
const updatedDroppableDimension = {
...target,
isEnabled,
};
const result: State = {
...state,
dimension: {
...state.dimension,
droppable: {
...state.dimension.droppable,
[id]: updatedDroppableDimension,
},
},
};
return updateStateAfterDimensionChange(result);
}
if (action.type === 'MOVE') {
const { client, windowScroll } = action.payload;
return move({
state,
clientSelection: client,
windowScroll,
shouldAnimate: false,
});
}
if (action.type === 'MOVE_BY_WINDOW_SCROLL') {
const { windowScroll } = action.payload;
if (!state.drag) {
console.error('cannot move with window scrolling if no current drag');
return clean();
}
return move({
state,
clientSelection: state.drag.current.client.selection,
windowScroll,
shouldAnimate: false,
});
}
if (action.type === 'MOVE_FORWARD' || action.type === 'MOVE_BACKWARD') {
if (state.phase !== 'DRAGGING') {
console.error('cannot move while not dragging', action);
return clean();
}
if (!state.drag) {
console.error('cannot move if there is no drag information');
return clean();
}
const existing: DragState = state.drag;
const isMovingForward: boolean = action.type === 'MOVE_FORWARD';
if (!existing.impact.destination) {
console.error('cannot move if there is no previous destination');
return clean();
}
const droppable: DroppableDimension = state.dimension.droppable[
existing.impact.destination.droppableId
];
const result: ?MoveToNextResult = moveToNextIndex({
isMovingForward,
draggableId: existing.initial.descriptor.id,
droppable,
draggables: state.dimension.draggable,
previousImpact: existing.impact,
});
// cannot move anyway (at the beginning or end of a list)
if (!result) {
return state;
}
const impact: DragImpact = result.impact;
const page: Position = result.pageCenter;
const client: Position = subtract(page, existing.current.windowScroll);
return move({
state,
impact,
clientSelection: client,
shouldAnimate: true,
});
}
if (action.type === 'CROSS_AXIS_MOVE_FORWARD' || action.type === 'CROSS_AXIS_MOVE_BACKWARD') {
if (state.phase !== 'DRAGGING') {
console.error('cannot move cross axis when not dragging');
return clean();
}
if (!state.drag) {
console.error('cannot move cross axis if there is no drag information');
return clean();
}
if (!state.drag.impact.destination) {
console.error('cannot move cross axis if not in a droppable');
return clean();
}
const current: CurrentDrag = state.drag.current;
const descriptor: DraggableDescriptor = state.drag.initial.descriptor;
const draggableId: DraggableId = descriptor.id;
const center: Position = current.page.center;
const droppableId: DroppableId = state.drag.impact.destination.droppableId;
const home: DraggableLocation = {
index: descriptor.index,
droppableId: descriptor.droppableId,
};
const result: ?MoveCrossAxisResult = moveCrossAxis({
isMovingForward: action.type === 'CROSS_AXIS_MOVE_FORWARD',
pageCenter: center,
draggableId,
droppableId,
home,
draggables: state.dimension.draggable,
droppables: state.dimension.droppable,
previousImpact: state.drag.impact,
});
if (!result) {
return state;
}
const page: Position = result.pageCenter;
const client: Position = subtract(page, current.windowScroll);
return move({
state,
clientSelection: client,
impact: result.impact,
shouldAnimate: true,
});
}
if (action.type === 'DROP_ANIMATE') {
const { trigger, newHomeOffset, impact, result } = action.payload;
if (state.phase !== 'DRAGGING') {
console.error('cannot animate drop while not dragging', action);
return state;
}
if (!state.drag) {
console.error('cannot animate drop - invalid drag state');
return clean();
}
const pending: PendingDrop = {
trigger,
newHomeOffset,
result,
impact,
};
return {
phase: 'DROP_ANIMATING',
drag: null,
drop: {
pending,
result: null,
},
dimension: state.dimension,
};
}
if (action.type === 'DROP_COMPLETE') {
const result: DropResult = action.payload;
return {
phase: 'DROP_COMPLETE',
drag: null,
drop: {
pending: null,
result,
},
dimension: noDimensions,
};
}
return state;
};