react-beautiful-dnd
Version:
Beautiful, accessible drag and drop for lists with React.js
547 lines (465 loc) • 13.8 kB
Flow
// @flow
import memoizeOne from 'memoize-one';
import type { TypeId,
Action,
State,
DraggableDimension,
DroppableDimension,
DraggableDimensionMap,
DroppableId,
DraggableId,
DimensionState,
DragImpact,
DragState,
DropResult,
CurrentDrag,
InitialDrag,
PendingDrop,
Phase,
DraggableLocation,
CurrentDragLocation,
Position,
InitialDragLocation,
} from '../types';
import getInitialImpact from './get-initial-impact';
import { add, subtract } from './position';
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/';
const noDimensions: DimensionState = {
request: null,
draggable: {},
droppable: {},
};
const origin: Position = { x: 0, y: 0 };
const clean = memoizeOne((phase: ?Phase): State => {
const state: State = {
// flow was not good with having a default arg on an optional type
phase: phase || 'IDLE',
drag: null,
drop: null,
dimension: noDimensions,
};
return state;
});
type MoveArgs = {|
state: State,
clientSelection: Position,
shouldAnimate?: boolean,
windowScroll ?: Position,
// force a custom drag impact
impact?: DragImpact,
|}
// TODO: move into own file and write tests
const move = ({
state,
clientSelection,
shouldAnimate = false,
windowScroll,
impact,
}: MoveArgs): State => {
if (state.phase !== 'DRAGGING') {
console.error('cannot move while not dragging');
return clean();
}
if (state.drag == null) {
console.error('cannot move if there is no drag information');
return clean();
}
const previous: CurrentDrag = state.drag.current;
const initial: InitialDrag = state.drag.initial;
const currentWindowScroll: Position = windowScroll || previous.windowScroll;
const client: CurrentDragLocation = (() => {
const offset: Position = subtract(clientSelection, initial.client.selection);
const result: CurrentDragLocation = {
offset,
selection: clientSelection,
center: add(offset, initial.client.center),
};
return result;
})();
const page: CurrentDragLocation = {
selection: add(client.selection, currentWindowScroll),
offset: add(client.offset, currentWindowScroll),
center: add(client.center, currentWindowScroll),
};
const current: CurrentDrag = {
id: previous.id,
type: previous.type,
isScrollAllowed: previous.isScrollAllowed,
client,
page,
shouldAnimate,
windowScroll: currentWindowScroll,
};
const previousDroppableOverId: ?DroppableId = state.drag && state.drag.impact.destination
? state.drag.impact.destination.droppableId
: null;
const newImpact: DragImpact = (impact || getDragImpact({
pageCenter: page.center,
draggable: state.dimension.draggable[current.id],
draggables: state.dimension.draggable,
droppables: state.dimension.droppable,
previousDroppableOverId,
}));
const drag: DragState = {
initial,
impact: newImpact,
current,
previous: {
droppableOverId: previousDroppableOverId,
},
};
return {
...state,
drag,
};
};
export default (state: State = clean('IDLE'), action: Action): State => {
if (action.type === 'BEGIN_LIFT') {
if (state.phase !== 'IDLE') {
console.error('trying to start a lift while another is occurring');
return state;
}
return clean('COLLECTING_DIMENSIONS');
}
if (action.type === 'REQUEST_DIMENSIONS') {
if (state.phase !== 'COLLECTING_DIMENSIONS') {
console.error('trying to collect dimensions at the wrong time');
return state;
}
const typeId: TypeId = action.payload;
return {
phase: 'COLLECTING_DIMENSIONS',
drag: null,
drop: null,
dimension: {
request: typeId,
draggable: {},
droppable: {},
},
};
}
if (action.type === 'PUBLISH_DRAGGABLE_DIMENSION') {
const dimension: DraggableDimension = action.payload;
if (state.phase !== 'COLLECTING_DIMENSIONS') {
console.warn('dimension rejected as no longer requesting dimensions', dimension);
return state;
}
if (state.dimension.draggable[dimension.id]) {
console.error(`dimension already exists for ${dimension.id}`);
return state;
}
return {
...state,
dimension: {
request: state.dimension.request,
droppable: state.dimension.droppable,
draggable: {
...state.dimension.draggable,
[dimension.id]: dimension,
},
},
};
}
if (action.type === 'PUBLISH_DROPPABLE_DIMENSION') {
const dimension: DroppableDimension = action.payload;
if (state.phase !== 'COLLECTING_DIMENSIONS') {
console.warn('dimension rejected as no longer requesting dimensions', dimension);
return state;
}
if (state.dimension.droppable[dimension.id]) {
console.error(`dimension already exists for ${dimension.id}`);
return state;
}
return {
...state,
dimension: {
request: state.dimension.request,
draggable: state.dimension.draggable,
droppable: {
...state.dimension.droppable,
[dimension.id]: dimension,
},
},
};
}
if (action.type === 'COMPLETE_LIFT') {
if (state.phase !== 'COLLECTING_DIMENSIONS') {
console.error('trying complete lift without collecting dimensions');
return state;
}
const { id, type, client, windowScroll, isScrollAllowed } = action.payload;
const draggables: DraggableDimensionMap = state.dimension.draggable;
const draggable: DraggableDimension = state.dimension.draggable[id];
const droppable: DroppableDimension = state.dimension.droppable[draggable.droppableId];
const page: InitialDragLocation = {
selection: add(client.selection, windowScroll),
center: add(client.center, windowScroll),
};
const impact: ?DragImpact = getInitialImpact({
draggable,
droppable,
draggables,
});
if (!impact || !impact.destination) {
console.error('invalid lift state');
return clean();
}
const source: DraggableLocation = impact.destination;
const initial: InitialDrag = {
source,
client,
page,
windowScroll,
};
const current: CurrentDrag = {
id,
type,
client: {
selection: client.selection,
center: client.center,
offset: origin,
},
page: {
selection: page.selection,
center: page.center,
offset: origin,
},
windowScroll,
isScrollAllowed,
shouldAnimate: false,
};
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.current.isScrollAllowed) {
return clean();
}
const { id, offset } = action.payload;
const target: ?DroppableDimension = state.dimension.droppable[id];
if (!target) {
console.error('cannot update a droppable that is not inside of the state', id);
return clean();
}
// TODO: do not break an existing dimension.
// Rather, have a different structure to store the scroll
// $ExpectError - flow does not like spread
const dimension: DroppableDimension = {
...target,
container: {
...target.container,
scroll: {
...target.container.scroll,
current: offset,
},
},
};
const withUpdatedDimension: State = {
...state,
dimension: {
request: state.dimension.request,
draggable: state.dimension.draggable,
droppable: {
...state.dimension.droppable,
[id]: dimension,
},
},
};
return move({
state: withUpdatedDimension,
clientSelection: state.drag.current.client.selection,
});
}
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.error('cannot update enabled flag on droppable that does not have a dimension');
return clean();
}
if (target.isEnabled === isEnabled) {
console.warn(`trying to set droppable isEnabled to ${isEnabled} but it is already ${isEnabled}`);
return state;
}
const updatedDroppableDimension = {
...target,
isEnabled,
};
return {
...state,
dimension: {
...state.dimension,
droppable: {
...state.dimension.droppable,
[id]: updatedDroppableDimension,
},
},
};
}
if (action.type === 'MOVE') {
const { client, windowScroll } = action.payload;
return move({
state,
clientSelection: client,
windowScroll,
});
}
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,
});
}
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.current.id,
impact: existing.impact,
droppable,
draggables: state.dimension.draggable,
});
// 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 draggableId: DraggableId = current.id;
const center: Position = current.page.center;
const droppableId: DroppableId = state.drag.impact.destination.droppableId;
const home: DraggableLocation = state.drag.initial.source;
const result: ?MoveCrossAxisResult = moveCrossAxis({
isMovingForward: action.type === 'CROSS_AXIS_MOVE_FORWARD',
pageCenter: center,
draggableId,
droppableId,
home,
draggables: state.dimension.draggable,
droppables: state.dimension.droppable,
});
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,
};
}
if (action.type === 'CLEAN') {
return clean();
}
return state;
};