UNPKG

react-beautiful-dnd

Version:

Beautiful, accessible drag and drop for lists with React.js

506 lines (430 loc) 12.2 kB
// @flow import type { DraggableId, DroppableId, DropResult, DragImpact, DraggableDimension, DroppableDimension, InitialDragPositions, DraggableLocation, Position, Dispatch, State, DropTrigger, CurrentDrag, InitialDrag, DraggableDescriptor, } from '../types'; import noImpact from './no-impact'; import getNewHomeClientCenter from './get-new-home-client-center'; import { add, subtract, isEqual } from './position'; const origin: Position = { x: 0, y: 0 }; type ScrollDiffArgs = {| initial: InitialDrag, current: CurrentDrag, droppable: ?DroppableDimension |} const getScrollDiff = ({ initial, current, droppable, }: ScrollDiffArgs): Position => { const windowScrollDiff: Position = subtract( initial.windowScroll, current.windowScroll ); const droppableScrollDiff: Position = droppable ? droppable.viewport.frameScroll.diff.displacement : origin; return add(windowScrollDiff, droppableScrollDiff); }; export type RequestDimensionsAction = {| type: 'REQUEST_DIMENSIONS', payload: DraggableId, |} export const requestDimensions = (id: DraggableId): RequestDimensionsAction => ({ type: 'REQUEST_DIMENSIONS', payload: id, }); export type CompleteLiftAction = {| type: 'COMPLETE_LIFT', payload: {| id: DraggableId, client: InitialDragPositions, windowScroll: Position, isScrollAllowed: boolean, |} |} export const completeLift = ( id: DraggableId, client: InitialDragPositions, windowScroll: Position, isScrollAllowed: boolean, ): CompleteLiftAction => ({ type: 'COMPLETE_LIFT', payload: { id, client, windowScroll, isScrollAllowed, }, }); export type PublishDraggableDimensionsAction = {| type: 'PUBLISH_DRAGGABLE_DIMENSIONS', payload: DraggableDimension[] |} export const publishDraggableDimensions = (dimensions: DraggableDimension[]): PublishDraggableDimensionsAction => ({ type: 'PUBLISH_DRAGGABLE_DIMENSIONS', payload: dimensions, }); export type PublishDroppableDimensionsAction = {| type: 'PUBLISH_DROPPABLE_DIMENSIONS', payload: DroppableDimension[] |} export const publishDroppableDimensions = (dimensions: DroppableDimension[]): PublishDroppableDimensionsAction => ({ type: 'PUBLISH_DROPPABLE_DIMENSIONS', payload: dimensions, }); export type UpdateDroppableDimensionScrollAction = {| type: 'UPDATE_DROPPABLE_DIMENSION_SCROLL', payload: { id: DroppableId, offset: Position, } |} export const updateDroppableDimensionScroll = (id: DroppableId, offset: Position): UpdateDroppableDimensionScrollAction => ({ type: 'UPDATE_DROPPABLE_DIMENSION_SCROLL', payload: { id, offset, }, }); export type UpdateDroppableDimensionIsEnabledAction = {| type: 'UPDATE_DROPPABLE_DIMENSION_IS_ENABLED', payload: { id: DroppableId, isEnabled: boolean, } |} export const updateDroppableDimensionIsEnabled = (id: DroppableId, isEnabled: boolean): UpdateDroppableDimensionIsEnabledAction => ({ type: 'UPDATE_DROPPABLE_DIMENSION_IS_ENABLED', payload: { id, isEnabled, }, }); export type MoveAction = {| type: 'MOVE', payload: {| id: DraggableId, client: Position, windowScroll: Position, |} |} export const move = (id: DraggableId, client: Position, windowScroll: Position): MoveAction => ({ type: 'MOVE', payload: { id, client, windowScroll, }, }); export type MoveByWindowScrollAction = {| type: 'MOVE_BY_WINDOW_SCROLL', payload: {| id: DraggableId, windowScroll: Position, |} |} export const moveByWindowScroll = (id: DraggableId, windowScroll: Position): MoveByWindowScrollAction => ({ type: 'MOVE_BY_WINDOW_SCROLL', payload: { id, windowScroll, }, }); export type MoveBackwardAction = {| type: 'MOVE_BACKWARD', payload: DraggableId |} export const moveBackward = (id: DraggableId): MoveBackwardAction => ({ type: 'MOVE_BACKWARD', payload: id, }); export type MoveForwardAction = {| type: 'MOVE_FORWARD', payload: DraggableId |} export const moveForward = (id: DraggableId): MoveForwardAction => ({ type: 'MOVE_FORWARD', payload: id, }); export type CrossAxisMoveForwardAction = {| type: 'CROSS_AXIS_MOVE_FORWARD', payload: DraggableId |} export const crossAxisMoveForward = (id: DraggableId): CrossAxisMoveForwardAction => ({ type: 'CROSS_AXIS_MOVE_FORWARD', payload: id, }); export type CrossAxisMoveBackwardAction = {| type: 'CROSS_AXIS_MOVE_BACKWARD', payload: DraggableId |} export const crossAxisMoveBackward = (id: DraggableId): CrossAxisMoveBackwardAction => ({ type: 'CROSS_AXIS_MOVE_BACKWARD', payload: id, }); type CleanAction = { type: 'CLEAN', payload: null, } export const clean = (): CleanAction => ({ type: 'CLEAN', payload: null, }); type PrepareAction = { type: 'PREPARE', payload: null, } export const prepare = (): PrepareAction => ({ type: 'PREPARE', payload: null, }); export type DropAnimateAction = { type: 'DROP_ANIMATE', payload: {| trigger: DropTrigger, newHomeOffset: Position, impact: DragImpact, result: DropResult, |} } type AnimateDropArgs = {| trigger: DropTrigger, newHomeOffset: Position, impact: DragImpact, result: DropResult |} const animateDrop = ({ trigger, newHomeOffset, impact, result, }: AnimateDropArgs): DropAnimateAction => ({ type: 'DROP_ANIMATE', payload: { trigger, newHomeOffset, impact, result, }, }); export type DropCompleteAction = { type: 'DROP_COMPLETE', payload: DropResult, } export const completeDrop = (result: DropResult): DropCompleteAction => ({ type: 'DROP_COMPLETE', payload: result, }); export const drop = () => (dispatch: Dispatch, getState: () => State): void => { const state: State = getState(); // dropped before a drag officially started - this is fine if (state.phase === 'PREPARING' || state.phase === 'COLLECTING_INITIAL_DIMENSIONS') { dispatch(clean()); return; } // dropped in another phase except for dragging - this is an error if (state.phase !== 'DRAGGING') { console.error(`not able to drop in phase: '${state.phase}'`); dispatch(clean()); return; } if (!state.drag) { console.error('not able to drop when there is invalid drag state', state); dispatch(clean()); return; } const { impact, initial, current } = state.drag; const descriptor: DraggableDescriptor = initial.descriptor; const draggable: DraggableDimension = state.dimension.draggable[initial.descriptor.id]; const home: DroppableDimension = state.dimension.droppable[draggable.descriptor.droppableId]; const destination: ?DroppableDimension = impact.destination ? state.dimension.droppable[impact.destination.droppableId] : null; const source: DraggableLocation = { droppableId: descriptor.droppableId, index: descriptor.index, }; const result: DropResult = { draggableId: descriptor.id, type: home.descriptor.type, source, destination: impact.destination, }; const newCenter: Position = getNewHomeClientCenter({ movement: impact.movement, draggable, draggables: state.dimension.draggable, destination, }); const clientOffset: Position = subtract(newCenter, draggable.client.withMargin.center); const scrollDiff: Position = getScrollDiff({ initial, current, droppable: destination || home, }); const newHomeOffset: Position = add(clientOffset, scrollDiff); // Do not animate if you do not need to. // This will be the case if either you are dragging with a // keyboard or if you manage to nail it just with a mouse. const isAnimationRequired = !isEqual( current.client.offset, newHomeOffset, ); if (!isAnimationRequired) { dispatch(completeDrop(result)); return; } dispatch(animateDrop({ trigger: 'DROP', newHomeOffset, impact, result, })); }; export const cancel = () => (dispatch: Dispatch, getState: () => State): void => { const state: State = getState(); // only allowing cancelling in the DRAGGING phase if (state.phase !== 'DRAGGING') { dispatch(clean()); return; } if (!state.drag) { console.error('invalid drag state', state); dispatch(clean()); return; } const { initial, current } = state.drag; const descriptor = initial.descriptor; const home: DroppableDimension = state.dimension.droppable[descriptor.droppableId]; const source: DraggableLocation = { index: descriptor.index, droppableId: descriptor.droppableId, }; const result: DropResult = { draggableId: descriptor.id, type: home.descriptor.type, source, // no destination when cancelling destination: null, }; const isAnimationRequired = !isEqual(current.client.offset, origin); if (!isAnimationRequired) { dispatch(completeDrop(result)); return; } const scrollDiff: Position = getScrollDiff({ initial, current, droppable: home }); dispatch(animateDrop({ trigger: 'CANCEL', newHomeOffset: scrollDiff, impact: noImpact, result, })); }; export const dropAnimationFinished = () => (dispatch: Dispatch, getState: () => State): void => { const state: State = getState(); if (state.phase !== 'DROP_ANIMATING') { console.error('cannot end drop that is no longer animating', state); dispatch(clean()); return; } if (!state.drop || !state.drop.pending) { console.error('cannot end drop that has no pending state', state); dispatch(clean()); return; } dispatch(completeDrop(state.drop.pending.result)); }; export type LiftAction = {| type: 'LIFT', payload: {| id: DraggableId, client: InitialDragPositions, windowScroll: Position, isScrollAllowed: boolean, |} |} // lifting with DraggableId rather than descriptor // as the descriptor might change after a drop is flushed export const lift = (id: DraggableId, client: InitialDragPositions, windowScroll: Position, isScrollAllowed: boolean, ) => (dispatch: Dispatch, getState: Function) => { // Phase 1: Quickly finish any current drop animations const initial: State = getState(); // flush dropping animation if needed // this can change the descriptor of the dragging item if (initial.phase === 'DROP_ANIMATING') { if (!initial.drop || !initial.drop.pending) { console.error('cannot flush drop animation if there is no pending'); dispatch(clean()); } else { // this can cause descriptor updates in the dimension marshal dispatch(completeDrop(initial.drop.pending.result)); } } // https://github.com/chenglou/react-motion/issues/437 // need to allow a flush of react-motion dispatch(prepare()); setTimeout(() => { // Phase 2: collect initial dimensions const state: State = getState(); // drag cancelled before timeout finished if (state.phase !== 'PREPARING') { return; } // will communicate with the marshal to start requesting dimensions dispatch(requestDimensions(id)); // Need to allow an opportunity for the dimensions to be requested. setTimeout(() => { // Phase 3: dimensions are collected: start a lift const newState: State = getState(); // drag was already cancelled before dimensions all collected if (newState.phase !== 'COLLECTING_INITIAL_DIMENSIONS') { return; } dispatch(completeLift(id, client, windowScroll, isScrollAllowed)); }); }); }; export type Action = CompleteLiftAction | RequestDimensionsAction | PublishDraggableDimensionsAction | PublishDroppableDimensionsAction | UpdateDroppableDimensionScrollAction | UpdateDroppableDimensionIsEnabledAction | MoveByWindowScrollAction | MoveAction | MoveBackwardAction | MoveForwardAction | CrossAxisMoveForwardAction | CrossAxisMoveBackwardAction | DropAnimateAction | DropCompleteAction | PrepareAction | CleanAction;