toggle-corp/react-store

View on GitHub
components/View/GridLayoutEditor/GridItem/index.js

Summary

Maintainability
A
0 mins
Test Coverage
import PropTypes from 'prop-types';
import React from 'react';

import {
    addClassName,
    removeClassName,
} from '../../../../utils/common';

import styles from './styles.scss';

const propTypes = {
    className: PropTypes.string,
    dragItemClassName: PropTypes.string.isRequired,

    itemKey: PropTypes.string.isRequired,
    datum: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types

    layoutSelector: PropTypes.func.isRequired,
    minSizeSelector: PropTypes.func.isRequired,

    layoutValidator: PropTypes.func.isRequired,
    onLayoutChange: PropTypes.func.isRequired,

    headerModifier: PropTypes.func.isRequired,
    contentModifier: PropTypes.func.isRequired,

    getParentScrollInfo: PropTypes.func.isRequired,
    scrollParentContainer: PropTypes.func.isRequired,
};

const defaultProps = {
    className: '',
};

const areLayoutsEqual = (l1, l2) => (
    l1.left === l2.left
    && l1.top === l2.top
    && l1.width === l2.width
    && l1.height === l2.height
);


const SCROLL_DISTANCE = 16;
const SCROLL_INTERVAL = 30;

export default class GridItem extends React.Component {
    static propTypes = propTypes;

    static defaultProps = defaultProps;

    constructor(props) {
        super(props);

        const {
            datum,
            layoutSelector,
            minSizeSelector,
        } = props;

        const layout = layoutSelector(datum);
        this.state = {
            layout,
        };

        this.containerRef = React.createRef();
        this.isMouseDown = false;
        this.isResizing = false;
        this.lastValidLayout = layout;
        this.minSize = minSizeSelector(datum);
        this.scrollInterval = undefined;
    }

    componentDidMount() {
        window.addEventListener('mouseup', this.handleMouseUp);

        const { dragItemClassName } = this.props;
        const { current: container } = this.containerRef;

        const dragItem = container.getElementsByClassName(dragItemClassName)[0];

        if (dragItem) {
            this.dragItem = dragItem;
            dragItem.addEventListener('mousedown', this.handleMouseDown);
            dragItem.addEventListener('mouseup', this.handleMouseUp);
            dragItem.style.cursor = 'move';
        }
    }

    // eslint-disable-next-line camelcase
    UNSAFE_componentWillReceiveProps(nextProps) {
        const {
            layoutSelector: oldLayoutSelector,
            datum: oldDatum,
        } = this.props;

        const {
            layoutSelector: newLayoutSelector,
            datum: newDatum,
            minSizeSelector,
        } = nextProps;

        if (oldLayoutSelector !== newLayoutSelector || oldDatum !== newDatum) {
            const newLayout = newLayoutSelector(newDatum);
            this.setState({ layout: newLayout });
            this.lastValidLayout = newLayout;
            this.minSize = minSizeSelector(newDatum);
        }
    }

    componentWillUnmount() {
        clearInterval(this.scrollInterval);
        window.removeEventListener('mouseup', this.handleMouseUp);
        window.removeEventListener('mousemove', this.handleMouseMove);

        if (this.dragItem) {
            this.dragItem.removeEventListener('mousedown', this.handleMouseDown);
            this.dragItem.removeEventListener('mouseup', this.handleMouseUp);
        }
    }

    testLayoutValidity = (newLayout) => {
        const {
            itemKey,
            layoutValidator,
        } = this.props;

        const { current: container } = this.containerRef;

        const {
            isLayoutValid,
            newPossibleLayout,
        } = layoutValidator(itemKey, newLayout, this.isResizing);

        if (isLayoutValid) {
            this.isLayoutValid = true;
            this.lastValidLayout = newLayout;
            removeClassName(container, styles.invalid);
            return;
        }

        if (newPossibleLayout) {
            const {
                isLayoutValid: isNewLayoutValid,
            } = layoutValidator(itemKey, newPossibleLayout, this.isResizing);

            if (isNewLayoutValid) {
                this.lastValidLayout = newPossibleLayout;
            }
        }

        this.isLayoutValid = false;
        addClassName(container, styles.invalid);
    }

    handleMouseDown = (e) => {
        this.lastScreenX = e.clientX;
        this.lastScreenY = e.clientY;

        const scroll = this.props.getParentScrollInfo();
        const { layout } = this.state;
        this.initialDx = (scroll.left + e.clientX) - layout.left;
        this.initialDy = (scroll.top + e.clientY) - layout.top;

        const { current: container } = this.containerRef;
        addClassName(container, styles.moving);

        window.addEventListener('mousemove', this.handleMouseMove);
        this.isMouseDown = true;
        this.isMoving = true;
    }

    handleMouseUp = (e) => {
        this.lastScreenX = e.clientX;
        this.lastScreenY = e.clientY;
        clearInterval(this.scrollInterval);

        if (!this.isMouseDown) {
            return;
        }

        this.isMouseDown = false;

        const { current: container } = this.containerRef;
        removeClassName(container, styles.moving);
        removeClassName(container, styles.resizing);

        window.removeEventListener('mousemove', this.handleMouseMove);

        const {
            onLayoutChange,
            itemKey,
        } = this.props;

        if (this.isLayoutValid) {
            const { layout } = this.state;
            onLayoutChange(itemKey, layout);
        } else {
            onLayoutChange(itemKey, this.lastValidLayout);
            this.isLayoutValid = true;
            removeClassName(container, styles.invalid);
        }

        this.isResizing = false;
        this.isMoving = false;
    }

    handleMouseMove = (e) => {
        const dx = e.clientX - this.lastScreenX;
        const dy = e.clientY - this.lastScreenY;
        this.lastScreenX = e.clientX;
        this.lastScreenY = e.clientY;

        // sx and sy represents by how much
        // the layout exceeds the current scroll area.
        const { sx, sy, scroll } = this.calcBoundsExcess();

        // We need to update position/size
        // either if sx/sy is zero or if mouse movement
        // is in opposite direction of sx/sy.

        // If it's in the same direction (sx * dx > 0), we let
        // the scrollLayout function to handle the layout update.
        const updateX = (sx * dx <= 0 && dx);
        const updateY = (sy * dy <= 0 && dy);

        // New width/height or left/top.
        // We take the initial distance of mouse from the layout
        // and maintain that relative distance.
        const newX = (scroll.left + e.clientX) - this.initialDx;
        const newY = (scroll.top + e.clientY) - this.initialDy;

        const { layout } = this.state;
        const newLayout = { ...layout };

        // Resize or move as required
        if (this.isResizing) {
            if (updateX && newX >= this.minSize.width) {
                newLayout.width = newX;
            }
            if (updateY && newY >= this.minSize.height) {
                newLayout.height = newY;
            }
        } else {
            if (updateX) {
                newLayout.left = newX;
            }
            if (updateY) {
                newLayout.top = newY;
            }
        }

        // If a new layout was calculated, update it.
        if (!areLayoutsEqual(layout, newLayout)) {
            this.setState({ layout: newLayout });
            this.testLayoutValidity(newLayout);
        }

        // Finally, we start the scrollLayout logic to happen at certain
        // intervals. This is becasue: if the mouseMove and mouseUp events
        // both are not called after this point,
        // then the mouse has probably moved outside the container and is stationary
        // which means we need to continuously scroll the container, updating the layout's
        // position/size at the same time.
        clearInterval(this.scrollInterval);
        this.scrollInterval = setInterval(this.scrollLayout, SCROLL_INTERVAL);
    }

    handleResizeHandleMouseDown = (e) => {
        e.stopPropagation();

        this.lastScreenX = e.clientX;
        this.lastScreenY = e.clientY;

        const scroll = this.props.getParentScrollInfo();
        const { layout } = this.state;
        this.initialDx = (scroll.left + e.clientX) - layout.width;
        this.initialDy = (scroll.top + e.clientY) - layout.height;

        const { current: container } = this.containerRef;
        addClassName(container, styles.resizing);

        window.addEventListener('mousemove', this.handleMouseMove);

        this.isResizing = true;
        this.isMouseDown = true;
    }

    calcBoundsExcess = () => {
        // Calculate if current layout exceeds the bounds
        // of current scroll area of parent container.
        // If so, return the scroll distance in that direction.
        // We use fake and static SCROLL_DISTANCE instead of actual
        // bounds to keep constant velocity while scrolling.

        const scroll = this.props.getParentScrollInfo();
        const { layout } = this.state;

        const {
            left,
            top,
            width,
            height,
        } = layout;

        const {
            left: scrollLeft,
            top: scrollTop,
            width: scrollWidth,
            height: scrollHeight,
        } = scroll;

        const right = left + width;
        const bottom = top + height;
        const scrollRight = scrollLeft + scrollWidth;
        const scrollBottom = scrollTop + scrollHeight;

        let sx = 0;
        if (right > scrollRight) {
            sx = SCROLL_DISTANCE;
        } else if (this.isResizing && right < scrollLeft) {
            sx = -SCROLL_DISTANCE;
        } else if (!this.isResizing && left < scrollLeft && scrollLeft > 0) {
            sx = -SCROLL_DISTANCE;
        }

        let sy = 0;
        if (bottom > scrollBottom) {
            sy = SCROLL_DISTANCE;
        } else if (this.isResizing && bottom < scrollTop) {
            sy = -SCROLL_DISTANCE;
        } else if (!this.isResizing && top < scrollTop && scrollTop > 0) {
            sy = -SCROLL_DISTANCE;
        }

        return { sx, sy, scroll };
    }

    scrollLayout = () => {
        // Update layout position/size and scroll the parent
        // container based on how much the layout has exceeded
        // the bounds of container.

        const { sx, sy } = this.calcBoundsExcess();
        if (!sx && !sy) {
            return;
        }

        this.setState((oldState) => {
            const layout = { ...oldState.layout };
            if (this.isResizing) {
                if (layout.width + sx >= this.minSize.width) {
                    layout.width += sx;
                }
                if (layout.height + sy >= this.minSize.height) {
                    layout.height += sy;
                }
            } else {
                layout.left += sx;
                layout.top += sy;
            }

            this.testLayoutValidity(layout);
            this.props.scrollParentContainer(sx, sy);
            return { layout };
        });
    }

    renderHeader = () => {
        const {
            datum,
            headerModifier,
        } = this.props;

        return headerModifier(datum);
    }

    renderContent = () => {
        const {
            datum,
            contentModifier,
        } = this.props;

        return contentModifier(datum);
    }

    renderResizeHandle = () => {
        const className = [
            styles.resizeHandle,
            'resize-handle',
        ].join(' ');

        return (
            // eslint-disable-next-line jsx-a11y/no-static-element-interactions
            <span
                className={className}
                onMouseDown={this.handleResizeHandleMouseDown}
                onMouseUp={this.handleMouseUp}
            />
        );
    }

    renderGhostItem = () => {
        const {
            width,
            height,
            left,
            top,
        } = this.lastValidLayout;

        const style = {
            width,
            height,
            minWidth: this.minSize.width,
            minHeight: this.minSize.height,
            transform: `translate(${left}px, ${top}px)`,
        };

        return (
            <div
                style={style}
                className={styles.ghostItem}
            >
                <div className={styles.inner} />
            </div>
        );
    }

    render() {
        const { className: classNameFromProps } = this.props;
        const { layout } = this.state;

        const className = `
            ${classNameFromProps}
            grid-item
            ${styles.gridItem}
        `;

        const Header = this.renderHeader;
        const Content = this.renderContent;
        const ResizeHandle = this.renderResizeHandle;
        const GhostItem = this.renderGhostItem;

        const style = {
            width: layout.width,
            height: layout.height,
            minWidth: this.minSize.width,
            minHeight: this.minSize.height,
            transform: `translate(${layout.left}px, ${layout.top}px)`,
        };

        return (
            <React.Fragment>
                <div
                    className={className}
                    ref={this.containerRef}
                    style={style}
                    // eslint-disable-next-line jsx-a11y/no-static-element-interactions
                    // onMouseDown={this.handleMouseDown}
                    // onMouseUp={this.handleMouseUp}
                >
                    <Header />
                    <Content />
                    <ResizeHandle />
                </div>
                <GhostItem />
            </React.Fragment>
        );
    }
}