react-beautiful-dnd
Version:
Beautiful, accessible drag and drop for lists with React.js
517 lines (442 loc) • 13.2 kB
JavaScript
// @flow
import type { Position } from 'css-box-model';
import invariant from 'tiny-invariant';
import { scrollDroppable } from './droppable-dimension';
import getDragImpact from './get-drag-impact';
// import publish from './publish';
import moveInDirection, {
type Result as MoveInDirectionResult,
} from './move-in-direction';
import { add, isEqual, subtract, origin } from './position';
import scrollViewport from './scroll-viewport';
import getHomeImpact from './get-home-impact';
import getPageItemPositions from './get-page-item-positions';
import isMovementAllowed from './is-movement-allowed';
import type {
State,
DroppableDimension,
PendingDrop,
IdleState,
PreparingState,
DraggingState,
ItemPositions,
DragPositions,
CollectingState,
DropAnimatingState,
DropPendingState,
DragImpact,
Viewport,
DimensionMap,
DropReason,
} from '../types';
import type { Action } from './store-types';
const idle: IdleState = { phase: 'IDLE' };
const preparing: PreparingState = { phase: 'PREPARING' };
type MoveArgs = {|
state: DraggingState | CollectingState,
clientSelection: Position,
shouldAnimate: boolean,
viewport?: Viewport,
// force a custom drag impact
impact?: ?DragImpact,
// provide a scroll jump request (optionally provided - and can be null)
scrollJumpRequest?: ?Position,
|};
const moveWithPositionUpdates = ({
state,
clientSelection,
shouldAnimate,
viewport,
impact,
scrollJumpRequest,
}: MoveArgs): CollectingState | DraggingState => {
// DRAGGING: can update position and impact
// COLLECTING: can update position but cannot update impact
const newViewport: Viewport = viewport || state.viewport;
const currentWindowScroll: Position = newViewport.scroll.current;
const client: ItemPositions = (() => {
const offset: Position = subtract(
clientSelection,
state.initial.client.selection,
);
return {
offset,
selection: clientSelection,
borderBoxCenter: add(state.initial.client.borderBoxCenter, offset),
};
})();
const page: ItemPositions = getPageItemPositions(client, currentWindowScroll);
const current: DragPositions = {
client,
page,
};
// Not updating impact while bulk collecting
if (state.phase === 'COLLECTING') {
return {
// adding phase to appease flow (even though it will be overwritten by spread)
phase: 'COLLECTING',
...state,
current,
};
}
const newImpact: DragImpact =
impact ||
getDragImpact({
pageBorderBoxCenter: page.borderBoxCenter,
draggable: state.dimensions.draggables[state.critical.draggable.id],
draggables: state.dimensions.draggables,
droppables: state.dimensions.droppables,
previousImpact: state.impact,
viewport: newViewport,
});
// dragging!
const result: DraggingState = {
...state,
current,
shouldAnimate,
impact: newImpact,
scrollJumpRequest: scrollJumpRequest || null,
viewport: newViewport,
};
return result;
};
export default (state: State = idle, action: Action): State => {
if (action.type === 'CLEAN') {
return idle;
}
if (action.type === 'PREPARE') {
return preparing;
}
if (action.type === 'INITIAL_PUBLISH') {
invariant(
state.phase === 'PREPARING',
'INITIAL_PUBLISH must come after a PREPARING phase',
);
const {
critical,
client,
viewport,
dimensions,
autoScrollMode,
} = action.payload;
const initial: DragPositions = {
client,
page: {
selection: add(client.selection, viewport.scroll.initial),
borderBoxCenter: add(client.selection, viewport.scroll.initial),
offset: origin,
},
};
const result: DraggingState = {
phase: 'DRAGGING',
isDragging: true,
critical,
autoScrollMode,
dimensions,
initial,
current: initial,
impact: getHomeImpact(critical, dimensions),
viewport,
scrollJumpRequest: null,
shouldAnimate: false,
};
return result;
}
if (action.type === 'COLLECTION_STARTING') {
// A collection might have restarted. We do not care as we are already in the right phase
// TODO: remove?
if (state.phase === 'COLLECTING' || state.phase === 'DROP_PENDING') {
return state;
}
invariant(
state.phase === 'DRAGGING',
`Collection cannot start from phase ${state.phase}`,
);
const result: CollectingState = {
// putting phase first to appease flow
phase: 'COLLECTING',
...state,
// eslint-disable-next-line
phase: 'COLLECTING',
};
return result;
}
if (action.type === 'PUBLISH') {
// Unexpected bulk publish
invariant(
state.phase === 'COLLECTING' || state.phase === 'DROP_PENDING',
`Unexpected ${action.type} received in phase ${state.phase}`,
);
invariant(
false,
`Dynamic additions and removals of Draggable and Droppable components
is currently not supported. But will be soon!`,
);
// return publish({
// state,
// publish: action.payload,
// });
}
if (action.type === 'MOVE') {
// Still preparing - ignore for now
if (state.phase === 'PREPARING') {
return state;
}
// Not allowing any more movements
if (state.phase === 'DROP_PENDING') {
return state;
}
invariant(
isMovementAllowed(state),
`${action.type} not permitted in phase ${state.phase}`,
);
const { client, shouldAnimate } = action.payload;
// nothing needs to be done
if (
state.shouldAnimate === shouldAnimate &&
isEqual(client, state.current.client.selection)
) {
return state;
}
// If we are jump scrolling - manual movements should not update the impact
const impact: ?DragImpact =
state.autoScrollMode === 'JUMP' ? state.impact : null;
return moveWithPositionUpdates({
state,
clientSelection: client,
impact,
shouldAnimate,
});
}
if (action.type === 'UPDATE_DROPPABLE_SCROLL') {
// Still preparing - ignore for now
if (state.phase === 'PREPARING') {
return state;
}
// Not allowing changes while a drop is pending
// Cannot get this during a DROP_ANIMATING as the dimension
// marshal will cancel any pending scroll updates
if (state.phase === 'DROP_PENDING') {
return state;
}
invariant(
isMovementAllowed(state),
`${action.type} not permitted in phase ${state.phase}`,
);
const { id, offset } = action.payload;
const target: ?DroppableDimension = state.dimensions.droppables[id];
// This is possible if a droppable has been asked to watch scroll but
// the dimension has not been published yet
if (!target) {
return state;
}
const updated: DroppableDimension = scrollDroppable(target, offset);
const dimensions: DimensionMap = {
...state.dimensions,
droppables: {
...state.dimensions.droppables,
[id]: updated,
},
};
const impact: DragImpact = (() => {
// flow is getting confused - so running this check again
invariant(isMovementAllowed(state));
// If we are jump scrolling - dimension changes should not update the impact
if (state.autoScrollMode === 'JUMP') {
return state.impact;
}
return getDragImpact({
pageBorderBoxCenter: state.current.page.borderBoxCenter,
draggable: dimensions.draggables[state.critical.draggable.id],
draggables: dimensions.draggables,
droppables: dimensions.droppables,
previousImpact: state.impact,
viewport: state.viewport,
});
})();
return {
// appeasing flow
phase: 'DRAGGING',
...state,
// eslint-disable-next-line
phase: state.phase,
impact,
dimensions,
// At this point any scroll jump request would need to be cleared
scrollJumpRequest: null,
};
}
if (action.type === 'UPDATE_DROPPABLE_IS_ENABLED') {
// Things are locked at this point
if (state.phase === 'DROP_PENDING') {
return state;
}
invariant(
isMovementAllowed(state),
`Attempting to move in an unsupported phase ${state.phase}`,
);
const { id, isEnabled } = action.payload;
const target: ?DroppableDimension = state.dimensions.droppables[id];
invariant(
target,
`Cannot find Droppable[id: ${id}] to toggle its enabled state`,
);
invariant(
target.isEnabled !== isEnabled,
`Trying to set droppable isEnabled to ${String(isEnabled)}
but it is already ${String(target.isEnabled)}`,
);
const updated: DroppableDimension = {
...target,
isEnabled,
};
const dimensions: DimensionMap = {
...state.dimensions,
droppables: {
...state.dimensions.droppables,
[id]: updated,
},
};
const impact: DragImpact = getDragImpact({
pageBorderBoxCenter: state.current.page.borderBoxCenter,
draggable: dimensions.draggables[state.critical.draggable.id],
draggables: dimensions.draggables,
droppables: dimensions.droppables,
previousImpact: state.impact,
viewport: state.viewport,
});
return {
// appeasing flow - this placeholder phase will be overwritten by spread
phase: 'DRAGGING',
...state,
// eslint-disable-next-line
phase: state.phase,
impact,
dimensions,
};
}
if (action.type === 'MOVE_BY_WINDOW_SCROLL') {
// Still preparing - ignore for now
// will be corrected in next window scroll
if (state.phase === 'PREPARING') {
return state;
}
// No longer accepting changes
if (state.phase === 'DROP_PENDING' || state.phase === 'DROP_ANIMATING') {
return state;
}
invariant(
isMovementAllowed(state),
`Cannot move by window in phase ${state.phase}`,
);
const newScroll: Position = action.payload.scroll;
// nothing needs to be done
if (isEqual(state.viewport.scroll.current, newScroll)) {
return state;
}
// If we are jump scrolling - any window scrolls should not update the impact
const isJumpScrolling: boolean = state.autoScrollMode === 'JUMP';
const impact: ?DragImpact = isJumpScrolling ? state.impact : null;
const viewport: Viewport = scrollViewport(state.viewport, newScroll);
return moveWithPositionUpdates({
state,
clientSelection: state.current.client.selection,
viewport,
shouldAnimate: false,
impact,
});
}
if (action.type === 'UPDATE_VIEWPORT_MAX_SCROLL') {
invariant(
state.isDragging,
'Cannot update the max viewport scroll if not dragging',
);
const existing: Viewport = state.viewport;
const viewport: Viewport = {
...existing,
scroll: {
...existing.scroll,
max: action.payload,
},
};
return {
// appeasing flow
phase: 'DRAGGING',
...state,
// eslint-disable-next-line
phase: state.phase,
viewport,
};
}
if (
action.type === 'MOVE_UP' ||
action.type === 'MOVE_DOWN' ||
action.type === 'MOVE_LEFT' ||
action.type === 'MOVE_RIGHT'
) {
// Still preparing - ignore for now
if (state.phase === 'PREPARING') {
return state;
}
// Not doing keyboard movements during these phases
if (state.phase === 'COLLECTING' || state.phase === 'DROP_PENDING') {
return state;
}
invariant(
state.phase === 'DRAGGING',
`${action.type} received while not in DRAGGING phase`,
);
const result: ?MoveInDirectionResult = moveInDirection({
state,
type: action.type,
});
// cannot mov in that direction
if (!result) {
return state;
}
return moveWithPositionUpdates({
state,
impact: result.impact,
clientSelection: result.clientSelection,
shouldAnimate: true,
scrollJumpRequest: result.scrollJumpRequest,
});
}
if (action.type === 'DROP_PENDING') {
const reason: DropReason = action.payload.reason;
invariant(
state.phase === 'COLLECTING',
'Can only move into the DROP_PENDING phase from the COLLECTING phase',
);
const newState: DropPendingState = {
// appeasing flow
phase: 'DROP_PENDING',
...state,
// eslint-disable-next-line
phase: 'DROP_PENDING',
isWaiting: true,
reason,
};
return newState;
}
if (action.type === 'DROP_ANIMATE') {
const pending: PendingDrop = action.payload;
invariant(
state.phase === 'DRAGGING' || state.phase === 'DROP_PENDING',
`Cannot animate drop from phase ${state.phase}`,
);
// Moving into a new phase
const result: DropAnimatingState = {
phase: 'DROP_ANIMATING',
pending,
dimensions: state.dimensions,
};
return result;
}
// Action will be used by hooks to call consumers
// We can simply return to the idle state
if (action.type === 'DROP_COMPLETE') {
return idle;
}
return state;
};