react-beautiful-dnd
Version:
Beautiful, accessible drag and drop for lists with React.js
319 lines (260 loc) • 8.2 kB
JavaScript
// @flow
import messagePreset from './message-preset';
import * as timings from '../../debug/timings';
import type { HookCaller } from './hooks-types';
import type {
Announce,
Hooks,
HookProvided,
State as AppState,
DragState,
DragStart,
DragUpdate,
DropResult,
DraggableLocation,
DraggableDescriptor,
DroppableDimension,
OnDragStartHook,
OnDragUpdateHook,
OnDragEndHook,
} from '../../types';
type State = {
isDragging: boolean,
start: ?DraggableLocation,
lastDestination: ?DraggableLocation,
hasMovedFromStartLocation: boolean,
}
type AnyHookFn = OnDragStartHook | OnDragUpdateHook | OnDragEndHook;
type AnyHookData = DragStart | DragUpdate | DropResult;
const withTimings = (key: string, fn: Function) => {
timings.start(key);
fn();
timings.finish(key);
};
const notDragging: State = {
isDragging: false,
start: null,
lastDestination: null,
hasMovedFromStartLocation: false,
};
const areLocationsEqual = (current: ?DraggableLocation, next: ?DraggableLocation) => {
// if both are null - we are equal
if (current == null && next == null) {
return true;
}
// if one is null - then they are not equal
if (current == null || next == null) {
return false;
}
// compare their actual values
return current.droppableId === next.droppableId &&
current.index === next.index;
};
const getAnnouncerForConsumer = (announce: Announce) => {
let wasCalled: boolean = false;
let isExpired: boolean = false;
// not allowing async announcements
setTimeout(() => {
isExpired = true;
});
const result = (message: string): void => {
if (wasCalled) {
console.warn('Announcement already made. Not making a second announcement');
return;
}
if (isExpired) {
console.warn(`
Announcements cannot be made asynchronously.
Default message has already been announced.
`);
return;
}
wasCalled = true;
announce(message);
};
// getter for isExpired
// using this technique so that a consumer cannot
// set the isExpired or wasCalled flags
result.wasCalled = (): boolean => wasCalled;
return result;
};
export default (announce: Announce): HookCaller => {
let state: State = notDragging;
const setState = (partial: Object): void => {
const newState: State = {
...state,
...partial,
};
state = newState;
};
const getDragStart = (appState: AppState): ?DragStart => {
if (!appState.drag) {
return null;
}
const descriptor: DraggableDescriptor = appState.drag.initial.descriptor;
const home: ?DroppableDimension = appState.dimension.droppable[descriptor.droppableId];
if (!home) {
return null;
}
const source: DraggableLocation = {
index: descriptor.index,
droppableId: descriptor.droppableId,
};
const start: DragStart = {
draggableId: descriptor.id,
type: home.descriptor.type,
source,
};
return start;
};
const execute = (
hook: ?AnyHookFn,
data: AnyHookData,
getDefaultMessage: (data: any) => string,
) => {
// if no hook: announce the default message
if (!hook) {
announce(getDefaultMessage(data));
return;
}
const managed: Announce = getAnnouncerForConsumer(announce);
const provided: HookProvided = {
announce: managed,
};
hook((data: any), provided);
if (!managed.wasCalled()) {
announce(getDefaultMessage(data));
}
};
const onDrag = (current: AppState, onDragUpdate: ?OnDragUpdateHook) => {
if (!state.isDragging) {
console.error('Cannot process dragging update if drag has not started');
return;
}
const drag: ?DragState = current.drag;
const start: ?DragStart = getDragStart(current);
if (!start || !drag) {
console.error('Cannot update drag when there is invalid state');
return;
}
const destination: ?DraggableLocation = drag.impact.destination;
const update: DragUpdate = {
draggableId: start.draggableId,
type: start.type,
source: start.source,
destination,
};
if (!state.hasMovedFromStartLocation) {
// has not moved past the home yet
if (areLocationsEqual(start.source, destination)) {
return;
}
// We have now moved past the home location
setState({
lastDestination: destination,
hasMovedFromStartLocation: true,
});
execute(onDragUpdate, update, messagePreset.onDragUpdate);
// announceMessage(update, onDragUpdate);
return;
}
// has not moved from the previous location
if (areLocationsEqual(state.lastDestination, destination)) {
return;
}
setState({
lastDestination: destination,
});
execute(onDragUpdate, update, messagePreset.onDragUpdate);
};
const onStateChange = (hooks: Hooks, previous: AppState, current: AppState): void => {
const { onDragStart, onDragUpdate, onDragEnd } = hooks;
const currentPhase = current.phase;
const previousPhase = previous.phase;
// Dragging in progress
if (currentPhase === 'DRAGGING' && previousPhase === 'DRAGGING') {
onDrag(current, onDragUpdate);
return;
}
// We are not in the dragging phase so we can clear this state
if (state.isDragging) {
setState(notDragging);
}
// From this point we only care about phase changes
if (currentPhase === previousPhase) {
return;
}
// Drag start
if (currentPhase === 'DRAGGING' && previousPhase !== 'DRAGGING') {
const start: ?DragStart = getDragStart(current);
if (!start) {
console.error('Unable to publish onDragStart');
return;
}
setState({
isDragging: true,
hasMovedFromStartLocation: false,
start,
});
// onDragStart is optional
withTimings('hook:onDragStart', () => execute(onDragStart, start, messagePreset.onDragStart));
return;
}
// Drag end
if (currentPhase === 'DROP_COMPLETE' && previousPhase !== 'DROP_COMPLETE') {
if (!current.drop || !current.drop.result) {
console.error('cannot fire onDragEnd hook without drag state', { current, previous });
return;
}
const result: DropResult = current.drop.result;
withTimings('hook:onDragEnd', () => execute(onDragEnd, result, messagePreset.onDragEnd));
return;
}
// Drag ended while dragging
if (currentPhase === 'IDLE' && previousPhase === 'DRAGGING') {
if (!previous.drag) {
console.error('cannot fire onDragEnd for cancel because cannot find previous drag');
return;
}
const descriptor: DraggableDescriptor = previous.drag.initial.descriptor;
const home: ?DroppableDimension = previous.dimension.droppable[descriptor.droppableId];
if (!home) {
console.error('cannot find dimension for home droppable');
return;
}
const source: DraggableLocation = {
index: descriptor.index,
droppableId: descriptor.droppableId,
};
const result: DropResult = {
draggableId: descriptor.id,
type: home.descriptor.type,
source,
destination: null,
reason: 'CANCEL',
};
withTimings('hook:onDragEnd (cancel)', () => execute(onDragEnd, result, messagePreset.onDragEnd));
return;
}
// Drag ended during a drop animation. Not super sure how this can even happen.
// This is being really safe
if (currentPhase === 'IDLE' && previousPhase === 'DROP_ANIMATING') {
if (!previous.drop || !previous.drop.pending) {
console.error('cannot fire onDragEnd for cancel because cannot find previous pending drop');
return;
}
const result: DropResult = {
draggableId: previous.drop.pending.result.draggableId,
type: previous.drop.pending.result.type,
source: previous.drop.pending.result.source,
destination: null,
reason: 'CANCEL',
};
execute(onDragEnd, result, messagePreset.onDragEnd);
}
};
const caller: HookCaller = {
onStateChange,
};
return caller;
};