UNPKG

react-beautiful-dnd

Version:

Beautiful, accessible drag and drop for lists with React.js

284 lines (235 loc) 7.96 kB
// @flow import { Component } from 'react'; import type { Node } from 'react'; import PropTypes from 'prop-types'; import memoizeOne from 'memoize-one'; import rafSchedule from 'raf-schd'; import getWindowScrollPosition from '../get-window-scroll-position'; import getArea from '../../state/get-area'; import { getDroppableDimension } from '../../state/dimension'; import getClosestScrollable from '../get-closest-scrollable'; import { dimensionMarshalKey } from '../context-keys'; import type { DimensionMarshal, DroppableCallbacks, } from '../../state/dimension-marshal/dimension-marshal-types'; import type { DroppableId, TypeId, DroppableDimension, DroppableDescriptor, Position, Area, Spacing, Direction, } from '../../types'; type Props = {| droppableId: DroppableId, // (it is being used) // eslint-disable-next-line react/no-unused-prop-types type: TypeId, direction: Direction, isDropDisabled: boolean, ignoreContainerClipping: boolean, isDropDisabled: boolean, targetRef: ?HTMLElement, children: Node, |} const origin: Position = { x: 0, y: 0 }; export default class DroppableDimensionPublisher extends Component<Props> { /* eslint-disable react/sort-comp */ closestScrollable: ?Element = null; isWatchingScroll: boolean = false; callbacks: DroppableCallbacks; publishedDescriptor: ?DroppableDescriptor = null; constructor(props: Props, context: mixed) { super(props, context); const callbacks: DroppableCallbacks = { getDimension: this.getDimension, watchScroll: this.watchScroll, unwatchScroll: this.unwatchScroll, }; this.callbacks = callbacks; } static contextTypes = { [dimensionMarshalKey]: PropTypes.object.isRequired, }; getDescriptor(props: Props) { const { droppableId, type } = props; const descriptor: DroppableDescriptor = this.getMemoizedDescriptor( droppableId, type, ); return descriptor; } getScrollOffset = (): Position => { if (!this.closestScrollable) { return origin; } const offset: Position = { x: this.closestScrollable.scrollLeft, y: this.closestScrollable.scrollTop, }; return offset; } memoizedUpdateScroll = memoizeOne((x: number, y: number) => { if (!this.publishedDescriptor) { console.error('Cannot update scroll on unpublished droppable'); return; } const newScroll: Position = { x, y }; const marshal: DimensionMarshal = this.context[dimensionMarshalKey]; marshal.updateDroppableScroll(this.publishedDescriptor.id, newScroll); }); scheduleScrollUpdate = rafSchedule((offset: Position) => { // might no longer be listening for scroll changes by the time a frame comes back if (this.isWatchingScroll) { this.memoizedUpdateScroll(offset.x, offset.y); } }); onClosestScroll = () => { this.scheduleScrollUpdate(this.getScrollOffset()); } watchScroll = () => { if (!this.props.targetRef) { console.error('cannot watch droppable scroll if not in the dom'); return; } // no closest parent if (this.closestScrollable == null) { return; } if (this.isWatchingScroll) { return; } this.isWatchingScroll = true; this.closestScrollable.addEventListener('scroll', this.onClosestScroll, { passive: true }); }; unwatchScroll = () => { // it is possible for the dimension publisher to tell this component to unwatch scroll // when it was not listening to a scroll if (!this.isWatchingScroll) { return; } this.isWatchingScroll = false; if (!this.closestScrollable) { console.error('cannot unbind event listener if element is null'); return; } this.closestScrollable.removeEventListener('scroll', this.onClosestScroll); } componentWillMount() { this.publish(this.getDescriptor(this.props)); } componentWillReceiveProps(nextProps: Props) { this.publish(this.getDescriptor(nextProps)); if (this.props.isDropDisabled === nextProps.isDropDisabled) { return; } // the enabled state of the droppable is changing const marshal: DimensionMarshal = this.context[dimensionMarshalKey]; marshal.updateDroppableIsEnabled(nextProps.droppableId, !nextProps.isDropDisabled); } componentWillUnmount() { if (this.isWatchingScroll) { console.warn('unmounting droppable while it was watching scroll'); this.unwatchScroll(); } this.unpublish(); } getMemoizedDescriptor = memoizeOne( (id: DroppableId, type: TypeId): DroppableDescriptor => ({ id, type, })); unpublish = () => { if (!this.publishedDescriptor) { console.error('cannot unpublish descriptor when none is published'); return; } // 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; } publish = (descriptor: DroppableDescriptor) => { if (descriptor === this.publishedDescriptor) { return; } if (this.publishedDescriptor) { this.unpublish(); } const marshal: DimensionMarshal = this.context[dimensionMarshalKey]; marshal.registerDroppable(descriptor, this.callbacks); this.publishedDescriptor = descriptor; } getDimension = (): DroppableDimension => { const { direction, ignoreContainerClipping, isDropDisabled, targetRef, } = this.props; if (!targetRef) { throw new Error('DimensionPublisher cannot calculate a dimension when not attached to the DOM'); } if (this.isWatchingScroll) { throw new Error('Attempting to recapture Droppable dimension while already watching scroll on previous capture'); } const descriptor: ?DroppableDescriptor = this.publishedDescriptor; if (!descriptor) { throw new Error('Cannot get dimension for unpublished droppable'); } // side effect - grabbing it for scroll listening so we know it is the same node this.closestScrollable = getClosestScrollable(targetRef); const frameScroll: Position = this.getScrollOffset(); const style: Object = window.getComputedStyle(targetRef); // keeping it simple and always using the margin of the droppable const margin: Spacing = { top: parseInt(style.marginTop, 10), right: parseInt(style.marginRight, 10), bottom: parseInt(style.marginBottom, 10), left: parseInt(style.marginLeft, 10), }; const padding: Spacing = { top: parseInt(style.paddingTop, 10), right: parseInt(style.paddingRight, 10), bottom: parseInt(style.paddingBottom, 10), left: parseInt(style.paddingLeft, 10), }; const client: Area = getArea(targetRef.getBoundingClientRect()); // The droppable's own bounds should be treated as the // container bounds in the following situations: // 1. The consumer has opted in to ignoring container clipping // 2. There is no scroll container // 3. The droppable has internal scrolling const frameClient: ?Area = (() => { if (ignoreContainerClipping) { return null; } if (!this.closestScrollable) { return null; } if (this.closestScrollable === targetRef) { return null; } return getArea(this.closestScrollable.getBoundingClientRect()); })(); const dimension: DroppableDimension = getDroppableDimension({ descriptor, direction, client, frameClient, frameScroll, margin, padding, windowScroll: getWindowScrollPosition(), isEnabled: !isDropDisabled, }); return dimension; } render() { return this.props.children; } }