react-beautiful-dnd
Version:
Beautiful, accessible drag and drop for lists with React.js
307 lines (262 loc) • 7.88 kB
JavaScript
// @flow
import invariant from 'tiny-invariant';
import messagePreset from './util/message-preset';
import * as timings from '../../debug/timings';
import type {
State,
DropResult,
Hooks,
HookProvided,
Critical,
DraggableLocation,
DragStart,
Announce,
DragUpdate,
OnBeforeDragStartHook,
OnDragStartHook,
OnDragUpdateHook,
OnDragEndHook,
} from '../../types';
import type {
Action,
Middleware,
MiddlewareStore,
Dispatch,
} from '../store-types';
type AnyPrimaryHookFn = OnDragStartHook | OnDragUpdateHook | OnDragEndHook;
type AnyHookData = DragStart | DragUpdate | DropResult;
const withTimings = (key: string, fn: Function) => {
timings.start(key);
fn();
timings.finish(key);
};
const areLocationsEqual = (
first: ?DraggableLocation,
second: ?DraggableLocation,
): boolean => {
// if both are null - we are equal
if (first == null && second == null) {
return true;
}
// if one is null - then they are not equal
if (first == null || second == null) {
return false;
}
// compare their actual values
return (
first.droppableId === second.droppableId && first.index === second.index
);
};
const isCriticalEqual = (first: Critical, second: Critical): boolean => {
if (first === second) {
return true;
}
const isDraggableEqual: boolean =
first.draggable.id === second.draggable.id &&
first.draggable.droppableId === second.draggable.droppableId &&
first.draggable.type === second.draggable.type &&
first.draggable.index === second.draggable.index;
const isDroppableEqual: boolean =
first.droppable.id === second.droppable.id &&
first.droppable.type === second.droppable.type;
return isDraggableEqual && isDroppableEqual;
};
const getExpiringAnnounce = (announce: Announce) => {
let wasCalled: boolean = false;
let isExpired: boolean = false;
// not allowing async announcements
setTimeout(() => {
isExpired = true;
});
const result = (message: string): void => {
if (wasCalled) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
'Announcement already made. Not making a second announcement',
);
}
return;
}
if (isExpired) {
if (process.env.NODE_ENV !== 'production') {
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;
};
const getDragStart = (critical: Critical): DragStart => ({
draggableId: critical.draggable.id,
type: critical.droppable.type,
source: {
droppableId: critical.droppable.id,
index: critical.draggable.index,
},
});
export default (getHooks: () => Hooks, announce: Announce): Middleware => {
const execute = (
hook: ?AnyPrimaryHookFn,
data: AnyHookData,
getDefaultMessage: (data: any) => string,
) => {
if (!hook) {
announce(getDefaultMessage(data));
return;
}
const willExpire: Announce = getExpiringAnnounce(announce);
const provided: HookProvided = {
announce: willExpire,
};
// Casting because we are not validating which data type is going into which hook
hook((data: any), provided);
if (!willExpire.wasCalled()) {
announce(getDefaultMessage(data));
}
};
const publisher = (() => {
let lastLocation: ?DraggableLocation = null;
let lastCritical: ?Critical = null;
let isDragStartPublished: boolean = false;
const beforeStart = (critical: Critical) => {
invariant(
!isDragStartPublished,
'Cannot fire onBeforeDragStart as a drag start has already been published',
);
withTimings('onBeforeDragStart', () => {
// No use of screen reader for this hook
const fn: ?OnBeforeDragStartHook = getHooks().onBeforeDragStart;
if (fn) {
fn(getDragStart(critical));
}
});
};
const start = (critical: Critical) => {
invariant(
!isDragStartPublished,
'Cannot fire onBeforeDragStart as a drag start has already been published',
);
const data: DragStart = getDragStart(critical);
lastCritical = critical;
lastLocation = data.source;
isDragStartPublished = true;
withTimings('onDragStart', () =>
execute(getHooks().onDragStart, data, messagePreset.onDragStart),
);
};
// Passing in the critical location again as it can change during a drag
const move = (critical: Critical, location: ?DraggableLocation) => {
invariant(
isDragStartPublished && lastCritical,
'Cannot fire onDragMove when onDragStart has not been called',
);
// Has the critical changed? Will result in a source change
const hasCriticalChanged: boolean = !isCriticalEqual(
critical,
lastCritical,
);
if (hasCriticalChanged) {
lastCritical = critical;
}
// Has the location changed? Will result in a destination change
const hasLocationChanged: boolean = !areLocationsEqual(
lastLocation,
location,
);
if (hasLocationChanged) {
lastLocation = location;
}
// Nothing has changed - no update needed
if (!hasCriticalChanged && !hasLocationChanged) {
return;
}
const data: DragUpdate = {
...getDragStart(critical),
destination: location,
};
withTimings('onDragUpdate', () =>
execute(getHooks().onDragUpdate, data, messagePreset.onDragUpdate),
);
};
const drop = (result: DropResult) => {
invariant(
isDragStartPublished,
'Cannot fire onDragEnd when there is no matching onDragStart',
);
isDragStartPublished = false;
lastLocation = null;
lastCritical = null;
withTimings('onDragEnd', () =>
execute(getHooks().onDragEnd, result, messagePreset.onDragEnd),
);
};
// A non user initiated cancel
const abort = () => {
invariant(
isDragStartPublished && lastCritical,
'Cannot cancel when onDragStart not fired',
);
const result: DropResult = {
...getDragStart(lastCritical),
destination: null,
reason: 'CANCEL',
};
drop(result);
};
return {
beforeStart,
start,
move,
drop,
abort,
isDragStartPublished: (): boolean => isDragStartPublished,
};
})();
return (store: MiddlewareStore) => (next: Dispatch) => (
action: Action,
): any => {
if (action.type === 'INITIAL_PUBLISH') {
const critical: Critical = action.payload.critical;
publisher.beforeStart(critical);
next(action);
publisher.start(critical);
return;
}
// All other hooks can fire after we have updated our connected components
next(action);
// Drag end
if (action.type === 'DROP_COMPLETE') {
const result: DropResult = action.payload;
publisher.drop(result);
return;
}
// Drag state resetting - need to check if
// we should fire a onDragEnd hook
if (action.type === 'CLEAN') {
// Unmatched drag start call - need to cancel
if (publisher.isDragStartPublished()) {
publisher.abort();
}
return;
}
// ## Perform drag updates
// No drag updates required
if (!publisher.isDragStartPublished()) {
return;
}
// impact of action has already been reduced
const state: State = store.getState();
if (state.phase === 'DRAGGING') {
publisher.move(state.critical, state.impact.destination);
}
};
};