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; };