// @flow import React from "react"; import PropTypes from "prop-types"; import isEqual from "lodash.isequal"; import classNames from "classnames"; import { autoBindHandlers, bottom, childrenEqual, cloneLayoutItem, compact, getLayoutItem, moveElement, synchronizeLayoutWithChildren, validateLayout, getAllCollisions, noop } from "./utils"; import GridItem from "./GridItem"; import type { ChildrenArray as ReactChildrenArray, Element as ReactElement } from "react"; // Types import type { EventCallback, CompactType, GridResizeEvent, GridDragEvent, Layout, LayoutItem } from "./utils"; type State = { activeDrag: ?LayoutItem, layout: Layout, mounted: boolean, oldDragItem: ?LayoutItem, oldLayout: ?Layout, oldResizeItem: ?LayoutItem }; export type Props = { className: string, style: Object, width: number, autoSize: boolean, cols: number, draggableCancel: string, draggableHandle: string, verticalCompact: boolean, compactType: ?("horizontal" | "vertical"), layout: Layout, margin: [number, number], containerPadding: [number, number] | null, rowHeight: number, maxRows: number, isDraggable: boolean, isResizable: boolean, preventCollision: boolean, useCSSTransforms: boolean, // Callbacks onLayoutChange: Layout => void, onDrag: EventCallback, onDragStart: EventCallback, onDragStop: EventCallback, onResize: EventCallback, onResizeStart: EventCallback, onResizeStop: EventCallback, children: ReactChildrenArray> }; // End Types /** * A reactive, fluid grid layout with draggable, resizable components. */ export default class ReactGridLayout extends React.Component { // TODO publish internal ReactClass displayName transform static displayName = "ReactGridLayout"; static propTypes = { // // Basic props // className: PropTypes.string, style: PropTypes.object, // This can be set explicitly. If it is not set, it will automatically // be set to the container width. Note that resizes will *not* cause this to adjust. // If you need that behavior, use WidthProvider. width: PropTypes.number, // If true, the container height swells and contracts to fit contents autoSize: PropTypes.bool, // # of cols. cols: PropTypes.number, // A selector that will not be draggable. draggableCancel: PropTypes.string, // A selector for the draggable handler draggableHandle: PropTypes.string, // Deprecated verticalCompact: function(props: Props) { if ( props.verticalCompact === false && process.env.NODE_ENV !== "production" ) { console.warn( // eslint-disable-line no-console "`verticalCompact` on is deprecated and will be removed soon. " + 'Use `compactType`: "horizontal" | "vertical" | null.' ); } }, // Choose vertical or hotizontal compaction compactType: PropTypes.oneOf(["vertical", "horizontal"]), // layout is an array of object with the format: // {x: Number, y: Number, w: Number, h: Number, i: String} layout: function(props: Props) { var layout = props.layout; // I hope you're setting the data-grid property on the grid items if (layout === undefined) return; validateLayout(layout, "layout"); }, // // Grid Dimensions // // Margin between items [x, y] in px margin: PropTypes.arrayOf(PropTypes.number), // Padding inside the container [x, y] in px containerPadding: PropTypes.arrayOf(PropTypes.number), // Rows have a static height, but you can change this based on breakpoints if you like rowHeight: PropTypes.number, // Default Infinity, but you can specify a max here if you like. // Note that this isn't fully fleshed out and won't error if you specify a layout that // extends beyond the row capacity. It will, however, not allow users to drag/resize // an item past the barrier. They can push items beyond the barrier, though. // Intentionally not documented for this reason. maxRows: PropTypes.number, // // Flags // isDraggable: PropTypes.bool, isResizable: PropTypes.bool, // If true, grid items won't change position when being dragged over. preventCollision: PropTypes.bool, // Use CSS transforms instead of top/left useCSSTransforms: PropTypes.bool, // // Callbacks // // Callback so you can save the layout. Calls after each drag & resize stops. onLayoutChange: PropTypes.func, // Calls when drag starts. Callback is of the signature (layout, oldItem, newItem, placeholder, e, ?node). // All callbacks below have the same signature. 'start' and 'stop' callbacks omit the 'placeholder'. onDragStart: PropTypes.func, // Calls on each drag movement. onDrag: PropTypes.func, // Calls when drag is complete. onDragStop: PropTypes.func, //Calls when resize starts. onResizeStart: PropTypes.func, // Calls when resize movement happens. onResize: PropTypes.func, // Calls when resize is complete. onResizeStop: PropTypes.func, // // Other validations // // Children must not have duplicate keys. children: function(props: Props, propName: string) { var children = props[propName]; // Check children keys for duplicates. Throw if found. var keys = {}; React.Children.forEach(children, function(child) { if (keys[child.key]) { throw new Error( 'Duplicate child key "' + child.key + '" found! This will cause problems in ReactGridLayout.' ); } keys[child.key] = true; }); } }; static defaultProps = { autoSize: true, cols: 12, className: "", style: {}, draggableHandle: "", draggableCancel: "", containerPadding: null, rowHeight: 150, maxRows: Infinity, // infinite vertical growth layout: [], margin: [10, 10], isDraggable: true, isResizable: true, useCSSTransforms: true, verticalCompact: true, compactType: "vertical", preventCollision: false, onLayoutChange: noop, onDragStart: noop, onDrag: noop, onDragStop: noop, onResizeStart: noop, onResize: noop, onResizeStop: noop }; state: State = { activeDrag: null, layout: synchronizeLayoutWithChildren( this.props.layout, this.props.children, this.props.cols, // Legacy support for verticalCompact: false this.compactType() ), mounted: false, oldDragItem: null, oldLayout: null, oldResizeItem: null }; constructor(props: Props, context: any): void { super(props, context); autoBindHandlers(this, [ "onDragStart", "onDrag", "onDragStop", "onResizeStart", "onResize", "onResizeStop" ]); } componentDidMount() { this.setState({ mounted: true }); // Possibly call back with layout on mount. This should be done after correcting the layout width // to ensure we don't rerender with the wrong width. this.onLayoutMaybeChanged(this.state.layout, this.props.layout); } componentWillReceiveProps(nextProps: Props) { let newLayoutBase; // Legacy support for compactType // Allow parent to set layout directly. if ( !isEqual(nextProps.layout, this.props.layout) || nextProps.compactType !== this.props.compactType ) { newLayoutBase = nextProps.layout; } else if (!childrenEqual(this.props.children, nextProps.children)) { // If children change, also regenerate the layout. Use our state // as the base in case because it may be more up to date than // what is in props. newLayoutBase = this.state.layout; } // We need to regenerate the layout. if (newLayoutBase) { const newLayout = synchronizeLayoutWithChildren( newLayoutBase, nextProps.children, nextProps.cols, this.compactType(nextProps) ); const oldLayout = this.state.layout; this.setState({ layout: newLayout }); this.onLayoutMaybeChanged(newLayout, oldLayout); } } /** * Calculates a pixel value for the container. * @return {String} Container height in pixels. */ containerHeight() { if (!this.props.autoSize) return; const nbRow = bottom(this.state.layout); const containerPaddingY = this.props.containerPadding ? this.props.containerPadding[1] : this.props.margin[1]; return ( nbRow * this.props.rowHeight + (nbRow - 1) * this.props.margin[1] + containerPaddingY * 2 + "px" ); } compactType(props: ?Object): CompactType { if (!props) props = this.props; return props.verticalCompact === false ? null : props.compactType; } /** * When dragging starts * @param {String} i Id of the child * @param {Number} x X position of the move * @param {Number} y Y position of the move * @param {Event} e The mousedown event * @param {Element} node The current dragging DOM element */ onDragStart(i: string, x: number, y: number, { e, node }: GridDragEvent) { const { layout } = this.state; var l = getLayoutItem(layout, i); if (!l) return; this.setState({ oldDragItem: cloneLayoutItem(l), oldLayout: this.state.layout }); return this.props.onDragStart(layout, l, l, null, e, node); } /** * Each drag movement create a new dragelement and move the element to the dragged location * @param {String} i Id of the child * @param {Number} x X position of the move * @param {Number} y Y position of the move * @param {Event} e The mousedown event * @param {Element} node The current dragging DOM element */ onDrag(i: string, x: number, y: number, { e, node }: GridDragEvent) { const { oldDragItem } = this.state; let { layout } = this.state; const { cols } = this.props; var l = getLayoutItem(layout, i); if (!l) return; // Create placeholder (display only) var placeholder = { w: l.w, h: l.h, x: l.x, y: l.y, placeholder: true, i: i }; // Move the element to the dragged location. const isUserAction = true; layout = moveElement( layout, l, x, y, isUserAction, this.props.preventCollision, this.compactType(), cols ); this.props.onDrag(layout, oldDragItem, l, placeholder, e, node); this.setState({ layout: compact(layout, this.compactType(), cols), activeDrag: placeholder }); } /** * When dragging stops, figure out which position the element is closest to and update its x and y. * @param {String} i Index of the child. * @param {Number} x X position of the move * @param {Number} y Y position of the move * @param {Event} e The mousedown event * @param {Element} node The current dragging DOM element */ onDragStop(i: string, x: number, y: number, { e, node }: GridDragEvent) { const { oldDragItem } = this.state; let { layout } = this.state; const { cols, preventCollision } = this.props; const l = getLayoutItem(layout, i); if (!l) return; // Move the element here const isUserAction = true; layout = moveElement( layout, l, x, y, isUserAction, preventCollision, this.compactType(), cols ); this.props.onDragStop(layout, oldDragItem, l, null, e, node); // Set state const newLayout = compact(layout, this.compactType(), cols); const { oldLayout } = this.state; this.setState({ activeDrag: null, layout: newLayout, oldDragItem: null, oldLayout: null }); this.onLayoutMaybeChanged(newLayout, oldLayout); } onLayoutMaybeChanged(newLayout: Layout, oldLayout: ?Layout) { if (!oldLayout) oldLayout = this.state.layout; if (!isEqual(oldLayout, newLayout)) { this.props.onLayoutChange(newLayout); } } onResizeStart(i: string, w: number, h: number, { e, node }: GridResizeEvent) { const { layout } = this.state; var l = getLayoutItem(layout, i); if (!l) return; this.setState({ oldResizeItem: cloneLayoutItem(l), oldLayout: this.state.layout }); this.props.onResizeStart(layout, l, l, null, e, node); } onResize(i: string, w: number, h: number, { e, node }: GridResizeEvent) { const { layout, oldResizeItem } = this.state; const { cols, preventCollision } = this.props; const l: ?LayoutItem = getLayoutItem(layout, i); if (!l) return; // Something like quad tree should be used // to find collisions faster let hasCollisions; if (preventCollision) { const collisions = getAllCollisions(layout, { ...l, w, h }).filter((layoutItem) => layoutItem.i !== l.i); hasCollisions = collisions.length > 0; // If we're colliding, we need adjust the placeholder. if (hasCollisions) { // adjust w && h to maximum allowed space let leastX = Infinity, leastY = Infinity; collisions.forEach((layoutItem) => { if (layoutItem.x > l.x) leastX = Math.min(leastX, layoutItem.x); if (layoutItem.y > l.y) leastY = Math.min(leastY, layoutItem.y); }); if (Number.isFinite(leastX)) l.w = leastX - l.x; if (Number.isFinite(leastY)) l.h = leastY - l.y; } } if (!hasCollisions) { // Set new width and height. l.w = w; l.h = h; } // Create placeholder element (display only) var placeholder = { w: l.w, h: l.h, x: l.x, y: l.y, static: true, i: i }; this.props.onResize(layout, oldResizeItem, l, placeholder, e, node); // Re-compact the layout and set the drag placeholder. this.setState({ layout: compact(layout, this.compactType(), cols), activeDrag: placeholder }); } onResizeStop(i: string, w: number, h: number, { e, node }: GridResizeEvent) { const { layout, oldResizeItem } = this.state; const { cols } = this.props; var l = getLayoutItem(layout, i); this.props.onResizeStop(layout, oldResizeItem, l, null, e, node); // Set state const newLayout = compact(layout, this.compactType(), cols); const { oldLayout } = this.state; this.setState({ activeDrag: null, layout: newLayout, oldResizeItem: null, oldLayout: null }); this.onLayoutMaybeChanged(newLayout, oldLayout); } /** * Create a placeholder object. * @return {Element} Placeholder div. */ placeholder(): ?ReactElement { const { activeDrag } = this.state; if (!activeDrag) return null; const { width, cols, margin, containerPadding, rowHeight, maxRows, useCSSTransforms } = this.props; // {...this.state.activeDrag} is pretty slow, actually return (
); } /** * Given a grid item, set its style attributes & surround in a . * @param {Element} child React element. * @return {Element} Element wrapped in draggable and properly placed. */ processGridItem(child: ReactElement): ?ReactElement { if (!child || !child.key) return; const l = getLayoutItem(this.state.layout, String(child.key)); if (!l) return null; const { width, cols, margin, containerPadding, rowHeight, maxRows, isDraggable, isResizable, useCSSTransforms, draggableCancel, draggableHandle } = this.props; const { mounted } = this.state; // Parse 'static'. Any properties defined directly on the grid item will take precedence. const draggable = Boolean( !l.static && isDraggable && (l.isDraggable || l.isDraggable == null) ); const resizable = Boolean( !l.static && isResizable && (l.isResizable || l.isResizable == null) ); return ( {child} ); } render() { const { className, style } = this.props; const mergedClassName = classNames("react-grid-layout", className); const mergedStyle = { height: this.containerHeight(), ...style }; return (
{React.Children.map(this.props.children, child => this.processGridItem(child) )} {this.placeholder()}
); } }