react-beautiful-dnd
Version:
Beautiful, accessible drag and drop for lists with React.js
406 lines (341 loc) • 11.2 kB
JSX
// @flow
import React, { type Node } from 'react';
import PropTypes from 'prop-types';
import memoizeOne from 'memoize-one';
import invariant from 'tiny-invariant';
import {
getBox,
withScroll,
createBox,
type BoxModel,
type Position,
type Spacing,
} from 'css-box-model';
import rafSchedule from 'raf-schd';
import getClosestScrollable from '../get-closest-scrollable';
import { dimensionMarshalKey } from '../context-keys';
import { origin } from '../../state/position';
import {
getDroppableDimension,
type Closest,
} from '../../state/droppable-dimension';
import type {
DimensionMarshal,
DroppableCallbacks,
} from '../../state/dimension-marshal/dimension-marshal-types';
import type {
DroppableId,
TypeId,
DroppableDimension,
DroppableDescriptor,
Direction,
ScrollOptions,
} from '../../types';
type Props = {|
droppableId: DroppableId,
type: TypeId,
direction: Direction,
isDropDisabled: boolean,
ignoreContainerClipping: boolean,
isDropDisabled: boolean,
getDroppableRef: () => ?HTMLElement,
children: Node,
|};
const getScroll = (el: Element): Position => ({
x: el.scrollLeft,
y: el.scrollTop,
});
// We currently do not support nested scroll containers
// But will hopefully support this soon!
const checkForNestedScrollContainers = (scrollable: ?Element) => {
if (process.env.NODE_ENV === 'production') {
return;
}
if (!scrollable) {
return;
}
const anotherScrollParent: ?Element = getClosestScrollable(
scrollable.parentElement,
);
if (!anotherScrollParent) {
return;
}
console.warn(`
Droppable: unsupported nested scroll container detected.
A Droppable can only have one scroll parent (which can be itself)
Nested scroll containers are currently not supported.
We hope to support nested scroll containers soon: https://github.com/atlassian/react-beautiful-dnd/issues/131
`);
};
type WatchingScroll = {|
closestScrollable: Element,
options: ScrollOptions,
|};
const listenerOptions = {
passive: true,
};
export default class DroppableDimensionPublisher extends React.Component<
Props,
> {
/* eslint-disable react/sort-comp */
watchingScroll: ?WatchingScroll = null;
callbacks: DroppableCallbacks;
publishedDescriptor: ?DroppableDescriptor = null;
constructor(props: Props, context: mixed) {
super(props, context);
const callbacks: DroppableCallbacks = {
getDimensionAndWatchScroll: this.getDimensionAndWatchScroll,
unwatchScroll: this.unwatchScroll,
scroll: this.scroll,
};
this.callbacks = callbacks;
}
static contextTypes = {
[dimensionMarshalKey]: PropTypes.object.isRequired,
};
getClosestScroll = (): Position => {
if (!this.watchingScroll) {
return origin;
}
return getScroll(this.watchingScroll.closestScrollable);
};
memoizedUpdateScroll = memoizeOne((x: number, y: number) => {
invariant(
this.publishedDescriptor,
'Cannot update scroll on unpublished droppable',
);
const newScroll: Position = { x, y };
const marshal: DimensionMarshal = this.context[dimensionMarshalKey];
marshal.updateDroppableScroll(this.publishedDescriptor.id, newScroll);
});
updateScroll = () => {
const offset: Position = this.getClosestScroll();
this.memoizedUpdateScroll(offset.x, offset.y);
};
scheduleScrollUpdate = rafSchedule(this.updateScroll);
onClosestScroll = () => {
invariant(
this.watchingScroll,
'Could not find scroll options while scrolling',
);
const options: ScrollOptions = this.watchingScroll.options;
if (options.shouldPublishImmediately) {
this.updateScroll();
return;
}
this.scheduleScrollUpdate();
};
scroll = (change: Position) => {
invariant(
this.watchingScroll,
'Cannot scroll a droppable with no closest scrollable',
);
const { closestScrollable } = this.watchingScroll;
closestScrollable.scrollTop += change.y;
closestScrollable.scrollLeft += change.x;
};
watchScroll = (closestScrollable: ?Element, options: ScrollOptions) => {
invariant(
!this.watchingScroll,
'Droppable cannot watch scroll as it is already watching scroll',
);
if (!closestScrollable) {
return;
}
this.watchingScroll = {
options,
closestScrollable,
};
closestScrollable.addEventListener(
'scroll',
this.onClosestScroll,
listenerOptions,
);
};
unwatchScroll = () => {
// Was not previously watching scroll.
// It is possible for a Droppable to be asked to unwatch a scroll
// (Eg it has not been collected yet, and the drag ends)
const watching: ?WatchingScroll = this.watchingScroll;
if (!watching) {
return;
}
this.scheduleScrollUpdate.cancel();
watching.closestScrollable.removeEventListener(
'scroll',
this.onClosestScroll,
listenerOptions,
);
this.watchingScroll = null;
};
componentDidMount() {
this.publish();
// Note: not calling `marshal.updateDroppableIsEnabled()`
// If the dimension marshal needs to get the dimension immediately
// then it will get the enabled state of the dimension at that point
}
componentDidUpdate(prevProps: Props) {
// Update the descriptor if needed
this.publish();
// We now need to check if the disabled flag has changed
if (this.props.isDropDisabled === prevProps.isDropDisabled) {
return;
}
// The enabled state of the droppable is changing.
// We need to let the marshal know incase a drag is currently occurring
const marshal: DimensionMarshal = this.context[dimensionMarshalKey];
marshal.updateDroppableIsEnabled(
this.props.droppableId,
!this.props.isDropDisabled,
);
}
componentWillUnmount() {
if (this.watchingScroll) {
if (process.env.NODE_ENV !== 'production') {
console.warn('Unmounting droppable while it was watching scroll');
}
this.unwatchScroll();
}
this.unpublish();
}
getMemoizedDescriptor = memoizeOne(
(id: DroppableId, type: TypeId): DroppableDescriptor => ({
id,
type,
}),
);
publish = () => {
const marshal: DimensionMarshal = this.context[dimensionMarshalKey];
const descriptor: DroppableDescriptor = this.getMemoizedDescriptor(
this.props.droppableId,
this.props.type,
);
if (!this.publishedDescriptor) {
marshal.registerDroppable(descriptor, this.callbacks);
this.publishedDescriptor = descriptor;
return;
}
// already published - and no changes
if (this.publishedDescriptor === descriptor) {
return;
}
// already published and there has been changes
marshal.updateDroppable(
this.publishedDescriptor,
descriptor,
this.callbacks,
);
this.publishedDescriptor = descriptor;
};
unpublish = () => {
invariant(
this.publishedDescriptor,
'Cannot unpublish descriptor when none is published',
);
// Using the previously published id to unpublish. This is to guard
// against the case where the id dynamically changes. This is not
// supported during a drag - but it is good to guard against.
const marshal: DimensionMarshal = this.context[dimensionMarshalKey];
marshal.unregisterDroppable(this.publishedDescriptor);
this.publishedDescriptor = null;
};
getDimensionAndWatchScroll = (
windowScroll: Position,
options: ScrollOptions,
): DroppableDimension => {
const {
direction,
ignoreContainerClipping,
isDropDisabled,
getDroppableRef,
} = this.props;
const targetRef: ?HTMLElement = getDroppableRef();
const descriptor: ?DroppableDescriptor = this.publishedDescriptor;
invariant(
targetRef,
'Cannot calculate a dimension when not attached to the DOM',
);
invariant(descriptor, 'Cannot get dimension for unpublished droppable');
const scrollableRef: ?Element = getClosestScrollable(targetRef);
// print a debug warning if using an unsupported nested scroll container setup
checkForNestedScrollContainers(scrollableRef);
// Side effect: watch scroll
// TODO: check that reducer can handle a scroll update before an initial publish
this.watchScroll(scrollableRef, options);
const client: BoxModel = (() => {
const base: BoxModel = getBox(targetRef);
// Droppable has no scroll parent
if (!scrollableRef) {
return base;
}
// Droppable is not the same as the closest scrollable
if (targetRef !== scrollableRef) {
return base;
}
// Droppable is scrollable
// Element.getBoundingClient() returns:
// When not scrollable: the full size of the element
// When scrollable: the visible size of the element
// (which is not the full width of its scrollable content)
// So we recalculate the borderBox of a scrollable droppable to give
// it its full dimensions. This will be cut to the correct size by the frame
// Creating the paddingBox based on scrollWidth / scrollTop
// scrollWidth / scrollHeight are based on the paddingBox of an element
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
const top: number = base.paddingBox.top - scrollableRef.scrollTop;
const left: number = base.paddingBox.left - scrollableRef.scrollLeft;
const bottom: number = top + scrollableRef.scrollHeight;
const right: number = left + scrollableRef.scrollWidth;
const paddingBox: Spacing = {
top,
right,
bottom,
left,
};
// Creating the borderBox by adding the borders to the paddingBox
const borderBox: Spacing = {
top: paddingBox.top - base.border.top,
right: paddingBox.right + base.border.right,
bottom: paddingBox.bottom + base.border.bottom,
left: paddingBox.left - base.border.left,
};
// We are not accounting for scrollbars
// Adjusting for scrollbars is hard because:
// - they are different between browsers
// - scrollbars can be activated and removed during a drag
// We instead account for this slightly in our auto scroller
return createBox({
borderBox,
margin: base.margin,
border: base.border,
padding: base.padding,
});
})();
const page: BoxModel = withScroll(client, windowScroll);
const closest: ?Closest = (() => {
if (!scrollableRef) {
return null;
}
const frameClient: BoxModel = getBox(scrollableRef);
return {
client: frameClient,
page: withScroll(frameClient),
scrollHeight: scrollableRef.scrollHeight,
scrollWidth: scrollableRef.scrollWidth,
scroll: getScroll(scrollableRef),
shouldClipSubject: !ignoreContainerClipping,
};
})();
return getDroppableDimension({
descriptor,
isEnabled: !isDropDisabled,
direction,
client,
page,
closest,
});
};
render() {
return this.props.children;
}
}