react-beautiful-dnd
Version:
Beautiful, accessible drag and drop for lists with React.js
450 lines (372 loc) • 12.8 kB
Flow
// @flow
import type {
DraggableId,
DroppableId,
DroppableDescriptor,
DraggableDescriptor,
DraggableDimension,
DroppableDimension,
State as AppState,
Phase,
Position,
} from '../../types';
import type {
DimensionMarshal,
Callbacks,
GetDraggableDimensionFn,
DroppableCallbacks,
UnknownDimensionType,
UnknownDescriptorType,
DroppableEntry,
DraggableEntry,
DroppableEntryMap,
DraggableEntryMap,
} from './dimension-marshal-types';
type State = {|
// long lived
droppables: DroppableEntryMap,
draggables: DraggableEntryMap,
isCollecting: boolean,
request: ?DraggableId,
frameId: ?number,
|}
type ToBePublished = {|
draggables: DraggableDimension[],
droppables: DroppableDimension[],
|}
export default (callbacks: Callbacks) => {
let state: State = {
droppables: {},
draggables: {},
isCollecting: false,
request: null,
frameId: null,
};
const setState = (partial: Object) => {
const newState: State = {
...state,
...partial,
};
state = newState;
};
const cancel = (...args: mixed[]) => {
console.error(...args);
if (!state.isCollecting) {
return;
}
// eslint-disable-next-line no-use-before-define
stopCollecting();
callbacks.cancel();
};
const registerDraggable = (
descriptor: DraggableDescriptor,
getDimension: GetDraggableDimensionFn
) => {
const id: DraggableId = descriptor.id;
// Cannot register a draggable if no entry exists for the droppable
if (!state.droppables[descriptor.droppableId]) {
cancel(`Cannot register Draggable ${id} as there is no entry for the Droppable ${descriptor.droppableId}`);
return;
}
// Not checking if the draggable already exists.
// This allows for overwriting in particular circumstances
const entry: DraggableEntry = {
descriptor,
getDimension,
};
const draggables: DraggableEntryMap = {
...state.draggables,
[id]: entry,
};
setState({
draggables,
});
if (!state.isCollecting) {
return;
}
// if a draggable is published while collecting - publishing it immediately
const dimension: DraggableDimension = entry.getDimension();
callbacks.publishDraggables([dimension]);
};
const registerDroppable = (
descriptor: DroppableDescriptor,
droppableCallbacks: DroppableCallbacks,
) => {
const id: DroppableId = descriptor.id;
// Not checking if there is already a droppable published with the same id
// In some situations a Droppable might be published with the same id as
// a Droppable that is about to be unmounted - but has not unpublished yet
const entry: DroppableEntry = {
descriptor,
callbacks: droppableCallbacks,
};
const droppables: DroppableEntryMap = {
...state.droppables,
[id]: entry,
};
setState({
droppables,
});
if (!state.isCollecting) {
return;
}
// if a droppable is published while collecting - publishing it immediately
const dimension: DroppableDimension = entry.callbacks.getDimension();
callbacks.publishDroppables([dimension]);
entry.callbacks.watchScroll();
};
const updateDroppableIsEnabled = (id: DroppableId, isEnabled: boolean) => {
if (!state.droppables[id]) {
cancel(`Cannot update the scroll on Droppable ${id} as it is not registered`);
return;
}
// no need to update the application state if a collection is not occurring
if (!state.isCollecting) {
return;
}
callbacks.updateDroppableIsEnabled(id, isEnabled);
};
const updateDroppableScroll = (id: DroppableId, newScroll: Position) => {
if (!state.droppables[id]) {
cancel(`Cannot update the scroll on Droppable ${id} as it is not registered`);
return;
}
// no need to update the application state if a collection is not occurring
if (!state.isCollecting) {
return;
}
callbacks.updateDroppableScroll(id, newScroll);
};
const unregisterDraggable = (descriptor: DraggableDescriptor) => {
const entry: ?DraggableEntry = state.draggables[descriptor.id];
if (!entry) {
cancel(`Cannot unregister Draggable with id ${descriptor.id} as it is not registered`);
return;
}
// Entry has already been overwritten.
// This can happen when a new Draggable with the same draggableId
// is mounted before the old Draggable has been removed.
if (entry.descriptor !== descriptor) {
return;
}
const newMap: DraggableEntryMap = {
...state.draggables,
};
delete newMap[descriptor.id];
setState({
draggables: newMap,
});
if (!state.isCollecting) {
return;
}
console.warn('currently not supporting unmounting a Draggable during a drag');
};
const unregisterDroppable = (descriptor: DroppableDescriptor) => {
const entry: ?DroppableEntry = state.droppables[descriptor.id];
if (!entry) {
cancel(`Cannot unregister Droppable with id ${descriptor.id} as as it is not registered`);
return;
}
// entry has already been overwritten
// in which can we will not remove it
if (entry.descriptor !== descriptor) {
return;
}
// Not checking if this will leave orphan draggables as react
// unmounts parents before it unmounts children:
// https://twitter.com/alexandereardon/status/941514612624703488
const newMap: DroppableEntryMap = {
...state.droppables,
};
delete newMap[descriptor.id];
setState({
droppables: newMap,
});
if (!state.isCollecting) {
return;
}
console.warn('currently not supporting unmounting a Droppable during a drag');
};
const getToBeCollected = (): UnknownDescriptorType[] => {
const draggables: DraggableEntryMap = state.draggables;
const droppables: DroppableEntryMap = state.droppables;
const request: ?DraggableId = state.request;
if (!request) {
console.error('cannot find request in state');
return [];
}
const descriptor: DraggableDescriptor = draggables[request].descriptor;
const home: DroppableDescriptor = droppables[descriptor.droppableId].descriptor;
const draggablesToBeCollected: DraggableDescriptor[] =
Object.keys(draggables)
.map((id: DraggableId): DraggableDescriptor => draggables[id].descriptor)
// remove the original draggable from the list
.filter((item: DraggableDescriptor): boolean => item.id !== descriptor.id)
// remove draggables that do not have the same droppable type
.filter((item: DraggableDescriptor): boolean => {
const entry: ?DroppableEntry = droppables[item.droppableId];
// This should never happen
// but it is better to print this information and continue on
if (!entry) {
console.warn(`Orphan Draggable found ${item.id} which says it belongs to unknown Droppable ${item.droppableId}`);
return false;
}
return entry.descriptor.type === home.type;
});
const droppablesToBeCollected: DroppableDescriptor[] =
Object.keys(droppables)
.map((id: DroppableId): DroppableDescriptor => droppables[id].descriptor)
// remove the home droppable from the list
.filter((item: DroppableDescriptor): boolean => item.id !== home.id)
// remove droppables with a different type
.filter((item: DroppableDescriptor): boolean => {
const droppable: DroppableDescriptor = droppables[item.id].descriptor;
return droppable.type === home.type;
});
const toBeCollected: UnknownDescriptorType[] = [
...droppablesToBeCollected,
...draggablesToBeCollected,
];
return toBeCollected;
};
const processPrimaryDimensions = (request: ?DraggableId) => {
if (state.isCollecting) {
cancel('Cannot start capturing dimensions for a drag it is already dragging');
return;
}
if (!request) {
cancel('Cannot start capturing dimensions with an invalid request', request);
return;
}
setState({
isCollecting: true,
request,
});
const draggables: DraggableEntryMap = state.draggables;
const droppables: DroppableEntryMap = state.droppables;
const draggableEntry: ?DraggableEntry = draggables[request];
if (!draggableEntry) {
cancel(`Cannot find Draggable with id ${request} to start collecting dimensions`);
return;
}
const homeEntry: ?DroppableEntry = droppables[draggableEntry.descriptor.droppableId];
if (!homeEntry) {
cancel(`Cannot find home Droppable [id:${draggableEntry.descriptor.droppableId}] for Draggable [id:${request}]`);
return;
}
// Get the minimum dimensions to start a drag
const home: DroppableDimension = homeEntry.callbacks.getDimension();
const draggable: DraggableDimension = draggableEntry.getDimension();
// Publishing dimensions
callbacks.publishDroppables([home]);
callbacks.publishDraggables([draggable]);
// Watching the scroll of the home droppable
homeEntry.callbacks.watchScroll();
};
const setFrameId = (frameId: ?number) => {
setState({
frameId,
});
};
const processSecondaryDimensions = (): void => {
if (!state.isCollecting) {
cancel('Cannot collect secondary dimensions when collection is not occurring');
return;
}
const toBeCollected: UnknownDescriptorType[] = getToBeCollected();
// Phase 1: collect dimensions in a single frame
const collectFrameId: number = requestAnimationFrame(() => {
const toBePublishedBuffer: UnknownDimensionType[] = toBeCollected.map(
(descriptor: UnknownDescriptorType): UnknownDimensionType => {
// is a droppable
if (descriptor.type) {
return state.droppables[descriptor.id].callbacks.getDimension();
}
// is a draggable
return state.draggables[descriptor.id].getDimension();
}
);
// Phase 2: publish all dimensions to the store
const publishFrameId: number = requestAnimationFrame(() => {
const toBePublished: ToBePublished = toBePublishedBuffer.reduce(
(previous: ToBePublished, dimension: UnknownDimensionType): ToBePublished => {
// is a draggable
if (dimension.placeholder) {
previous.draggables.push(dimension);
} else {
previous.droppables.push(dimension);
}
return previous;
}, { draggables: [], droppables: [] }
);
if (toBePublished.droppables.length) {
callbacks.publishDroppables(toBePublished.droppables);
}
if (toBePublished.draggables.length) {
callbacks.publishDraggables(toBePublished.draggables);
}
// need to watch the scroll on each droppable
toBePublished.droppables.forEach((dimension: DroppableDimension) => {
const entry: DroppableEntry = state.droppables[dimension.descriptor.id];
entry.callbacks.watchScroll();
});
setFrameId(null);
});
setFrameId(publishFrameId);
});
setFrameId(collectFrameId);
};
const stopCollecting = () => {
// Tell all droppables to stop watching scroll
// all good if they where not already listening
Object.keys(state.droppables)
.forEach((id: DroppableId) => state.droppables[id].callbacks.unwatchScroll());
if (state.frameId) {
cancelAnimationFrame(state.frameId);
}
// reset collections state
setState({
isCollecting: false,
request: null,
frameId: null,
});
};
const onPhaseChange = (current: AppState) => {
const phase: Phase = current.phase;
if (phase === 'COLLECTING_INITIAL_DIMENSIONS') {
processPrimaryDimensions(current.dimension.request);
return;
}
if (phase === 'DRAGGING') {
if (current.dimension.request !== state.request) {
cancel('Request in local state does not match that of the store');
return;
}
processSecondaryDimensions();
return;
}
// No need to collect any more as the user has finished interacting
if (phase === 'DROP_ANIMATING' || phase === 'DROP_COMPLETE') {
if (state.isCollecting) {
stopCollecting();
}
return;
}
// drag potentially cleaned
if (phase === 'IDLE') {
if (state.isCollecting) {
stopCollecting();
}
}
};
const marshal: DimensionMarshal = {
registerDraggable,
unregisterDraggable,
registerDroppable,
unregisterDroppable,
updateDroppableIsEnabled,
updateDroppableScroll,
onPhaseChange,
};
return marshal;
};