@hello-pangea/dnd
Version:
Beautiful and accessible drag and drop for lists with React
444 lines (372 loc) • 11.7 kB
text/typescript
import type { Position } from 'css-box-model';
import { invariant } from '../invariant';
import type {
DimensionMap,
State,
StateWhenUpdatesAllowed,
DraggableDimension,
DroppableDimension,
IdleState,
DraggingState,
DragPositions,
ClientPositions,
CollectingState,
DropAnimatingState,
DropPendingState,
Viewport,
DropReason,
} from '../types';
import type { Action } from './store-types';
import type { PublicResult as MoveInDirectionResult } from './move-in-direction/move-in-direction-types';
import scrollDroppable from './droppable/scroll-droppable';
import moveInDirection from './move-in-direction';
import { add, isEqual, origin } from './position';
import scrollViewport from './scroll-viewport';
import isMovementAllowed from './is-movement-allowed';
import { toDroppableList } from './dimension-structures';
import update from './post-reducer/when-moving/update';
import refreshSnap from './post-reducer/when-moving/refresh-snap';
import getLiftEffect from './get-lift-effect';
import patchDimensionMap from './patch-dimension-map';
import publishWhileDraggingInVirtual from './publish-while-dragging-in-virtual';
const isSnapping = (state: StateWhenUpdatesAllowed): boolean =>
state.movementMode === 'SNAP';
const postDroppableChange = (
state: StateWhenUpdatesAllowed,
updated: DroppableDimension,
isEnabledChanging: boolean,
): StateWhenUpdatesAllowed => {
const dimensions: DimensionMap = patchDimensionMap(state.dimensions, updated);
// if the enabled state is changing, we need to force a update
if (!isSnapping(state) || isEnabledChanging) {
return update({
state,
dimensions,
});
}
return refreshSnap({
state,
dimensions,
});
};
function removeScrollJumpRequest(
state: DraggingState | CollectingState | DropPendingState,
): DraggingState | CollectingState | DropPendingState {
if (state.isDragging && state.movementMode === 'SNAP') {
return {
...state,
scrollJumpRequest: null,
};
}
return state;
}
const idle: IdleState = { phase: 'IDLE', completed: null, shouldFlush: false };
// eslint-disable-next-line default-param-last
export default (state: State = idle, action: Action): State => {
if (action.type === 'FLUSH') {
return {
...idle,
shouldFlush: true,
};
}
if (action.type === 'INITIAL_PUBLISH') {
invariant(
state.phase === 'IDLE',
'INITIAL_PUBLISH must come after a IDLE phase',
);
const { critical, clientSelection, viewport, dimensions, movementMode } =
action.payload;
const draggable: DraggableDimension =
dimensions.draggables[critical.draggable.id];
const home: DroppableDimension =
dimensions.droppables[critical.droppable.id];
const client: ClientPositions = {
selection: clientSelection,
borderBoxCenter: draggable.client.borderBox.center,
offset: origin,
};
const initial: DragPositions = {
client,
page: {
selection: add(client.selection, viewport.scroll.initial),
borderBoxCenter: add(client.selection, viewport.scroll.initial),
offset: add(client.selection, viewport.scroll.diff.value),
},
};
// Can only auto scroll the window if every list is not fixed on the page
const isWindowScrollAllowed: boolean = toDroppableList(
dimensions.droppables,
).every((item: DroppableDimension) => !item.isFixedOnPage);
const { impact, afterCritical } = getLiftEffect({
draggable,
home,
draggables: dimensions.draggables,
viewport,
});
const result: DraggingState = {
phase: 'DRAGGING',
isDragging: true,
critical,
movementMode,
dimensions,
initial,
current: initial,
isWindowScrollAllowed,
impact,
afterCritical,
onLiftImpact: impact,
viewport,
scrollJumpRequest: null,
forceShouldAnimate: null,
};
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 = {
...state,
phase: 'COLLECTING',
};
return result;
}
if (action.type === 'PUBLISH_WHILE_DRAGGING') {
// Unexpected bulk publish
invariant(
state.phase === 'COLLECTING' || state.phase === 'DROP_PENDING',
`Unexpected ${action.type} received in phase ${state.phase}`,
);
return publishWhileDraggingInVirtual({
state,
published: action.payload,
});
}
if (action.type === 'MOVE') {
// 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: clientSelection } = action.payload;
// nothing needs to be done
if (isEqual(clientSelection, state.current.client.selection)) {
return state;
}
return update({
state,
clientSelection,
// If we are snap moving - manual movements should not update the impact
impact: isSnapping(state) ? state.impact : null,
});
}
if (action.type === 'UPDATE_DROPPABLE_SCROLL') {
// 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 removeScrollJumpRequest(state);
}
// We will be updating the scroll in response to dynamic changes
// manually on the droppable so we can ignore this change
if (state.phase === 'COLLECTING') {
return removeScrollJumpRequest(state);
}
invariant(
isMovementAllowed(state),
`${action.type} not permitted in phase ${state.phase}`,
);
const { id, newScroll } = action.payload;
const target: DroppableDimension | null = 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 scrolled: DroppableDimension = scrollDroppable(target, newScroll);
return postDroppableChange(state, scrolled, false);
}
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 | null = 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,
};
return postDroppableChange(state, updated, true);
}
if (action.type === 'UPDATE_DROPPABLE_IS_COMBINE_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, isCombineEnabled } = action.payload;
const target: DroppableDimension | null = state.dimensions.droppables[id];
invariant(
target,
`Cannot find Droppable[id: ${id}] to toggle its isCombineEnabled state`,
);
invariant(
target.isCombineEnabled !== isCombineEnabled,
`Trying to set droppable isCombineEnabled to ${String(isCombineEnabled)}
but it is already ${String(target.isCombineEnabled)}`,
);
const updated: DroppableDimension = {
...target,
isCombineEnabled,
};
return postDroppableChange(state, updated, true);
}
if (action.type === 'MOVE_BY_WINDOW_SCROLL') {
// 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}`,
);
invariant(
state.isWindowScrollAllowed,
'Window scrolling is currently not supported for fixed lists',
);
const newScroll: Position = action.payload.newScroll;
// nothing needs to be done
if (isEqual(state.viewport.scroll.current, newScroll)) {
return removeScrollJumpRequest(state);
}
const viewport: Viewport = scrollViewport(state.viewport, newScroll);
if (isSnapping(state)) {
return refreshSnap({
state,
viewport,
});
}
return update({
state,
viewport,
});
}
if (action.type === 'UPDATE_VIEWPORT_MAX_SCROLL') {
// Could occur if a transitionEnd occurs after a drag ends
if (!isMovementAllowed(state)) {
return state;
}
const maxScroll: Position = action.payload.maxScroll;
if (isEqual(maxScroll, state.viewport.scroll.max)) {
return state;
}
const withMaxScroll: Viewport = {
...state.viewport,
scroll: {
...state.viewport.scroll,
max: maxScroll,
},
};
// don't need to recalc any updates
return {
...state,
viewport: withMaxScroll,
};
}
if (
action.type === 'MOVE_UP' ||
action.type === 'MOVE_DOWN' ||
action.type === 'MOVE_LEFT' ||
action.type === 'MOVE_RIGHT'
) {
// 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 | null = moveInDirection({
state,
type: action.type,
});
// cannot move in that direction
if (!result) {
return state;
}
return update({
state,
impact: result.impact,
clientSelection: result.clientSelection,
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 = {
...state,
phase: 'DROP_PENDING',
isWaiting: true,
reason,
};
return newState;
}
if (action.type === 'DROP_ANIMATE') {
const { completed, dropDuration, newHomeClientOffset } = 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',
completed,
dropDuration,
newHomeClientOffset,
dimensions: state.dimensions,
};
return result;
}
// Action will be used by responders to call consumers
// We can simply return to the idle state
if (action.type === 'DROP_COMPLETE') {
const { completed } = action.payload;
return {
phase: 'IDLE',
completed,
shouldFlush: false,
};
}
return state;
};