bokuweb/react-resizable-box

View on GitHub
src/index.tsx

Summary

Maintainability
F
1 wk
Test Coverage
import * as React from 'react';
import { flushSync } from 'react-dom';

import { Resizer, Direction } from './resizer';

const DEFAULT_SIZE = {
  width: 'auto',
  height: 'auto',
};

export type ResizeDirection = Direction;

export interface Enable {
  top?: boolean;
  right?: boolean;
  bottom?: boolean;
  left?: boolean;
  topRight?: boolean;
  bottomRight?: boolean;
  bottomLeft?: boolean;
  topLeft?: boolean;
}

export interface HandleStyles {
  top?: React.CSSProperties;
  right?: React.CSSProperties;
  bottom?: React.CSSProperties;
  left?: React.CSSProperties;
  topRight?: React.CSSProperties;
  bottomRight?: React.CSSProperties;
  bottomLeft?: React.CSSProperties;
  topLeft?: React.CSSProperties;
}

export interface HandleClassName {
  top?: string;
  right?: string;
  bottom?: string;
  left?: string;
  topRight?: string;
  bottomRight?: string;
  bottomLeft?: string;
  topLeft?: string;
}

export interface Size {
  width?: string | number;
  height?: string | number;
}

export interface NumberSize {
  width: number;
  height: number;
}

export interface HandleComponent {
  top?: React.ReactElement<any>;
  right?: React.ReactElement<any>;
  bottom?: React.ReactElement<any>;
  left?: React.ReactElement<any>;
  topRight?: React.ReactElement<any>;
  bottomRight?: React.ReactElement<any>;
  bottomLeft?: React.ReactElement<any>;
  topLeft?: React.ReactElement<any>;
}

export type ResizeCallback = (
  event: MouseEvent | TouchEvent,
  direction: Direction,
  elementRef: HTMLElement,
  delta: NumberSize,
) => void;

export type ResizeStartCallback = (
  e: React.MouseEvent<HTMLElement> | React.TouchEvent<HTMLElement>,
  dir: Direction,
  elementRef: HTMLElement,
) => void | boolean;

export interface ResizableProps {
  as?: string | React.ComponentType<any>;
  style?: React.CSSProperties;
  className?: string;
  grid?: [number, number];
  snap?: {
    x?: number[];
    y?: number[];
  };
  snapGap?: number;
  bounds?: 'parent' | 'window' | HTMLElement;
  boundsByDirection?: boolean;
  size?: Size;
  minWidth?: string | number;
  minHeight?: string | number;
  maxWidth?: string | number;
  maxHeight?: string | number;
  lockAspectRatio?: boolean | number;
  lockAspectRatioExtraWidth?: number;
  lockAspectRatioExtraHeight?: number;
  enable?: Enable | false;
  handleStyles?: HandleStyles;
  handleClasses?: HandleClassName;
  handleWrapperStyle?: React.CSSProperties;
  handleWrapperClass?: string;
  handleComponent?: HandleComponent;
  children?: React.ReactNode;
  onResizeStart?: ResizeStartCallback;
  onResize?: ResizeCallback;
  onResizeStop?: ResizeCallback;
  defaultSize?: Size;
  scale?: number;
  resizeRatio?: number | [number, number];
}

interface State {
  isResizing: boolean;
  direction: Direction;
  original: {
    x: number;
    y: number;
    width: number;
    height: number;
  };
  width: number | string;
  height: number | string;

  backgroundStyle: React.CSSProperties;
  flexBasis?: string | number;
}

const clamp = (n: number, min: number, max: number): number => Math.max(Math.min(n, max), min);
const snap = (n: number, size: number): number => Math.round(n / size) * size;
const hasDirection = (dir: 'top' | 'right' | 'bottom' | 'left', target: string): boolean =>
  new RegExp(dir, 'i').test(target);

// INFO: In case of window is a Proxy and does not porxy Events correctly, use isTouchEvent & isMouseEvent to distinguish event type instead of `instanceof`.
const isTouchEvent = (event: MouseEvent | TouchEvent): event is TouchEvent => {
  return Boolean((event as TouchEvent).touches && (event as TouchEvent).touches.length);
};

const isMouseEvent = (event: MouseEvent | TouchEvent): event is MouseEvent => {
  return Boolean(
    ((event as MouseEvent).clientX || (event as MouseEvent).clientX === 0) &&
      ((event as MouseEvent).clientY || (event as MouseEvent).clientY === 0),
  );
};

const findClosestSnap = (n: number, snapArray: number[], snapGap: number = 0): number => {
  const closestGapIndex = snapArray.reduce(
    (prev, curr, index) => (Math.abs(curr - n) < Math.abs(snapArray[prev] - n) ? index : prev),
    0,
  );
  const gap = Math.abs(snapArray[closestGapIndex] - n);

  return snapGap === 0 || gap < snapGap ? snapArray[closestGapIndex] : n;
};

const getStringSize = (n: number | string): string => {
  n = n.toString();
  if (n === 'auto') {
    return n;
  }
  if (n.endsWith('px')) {
    return n;
  }
  if (n.endsWith('%')) {
    return n;
  }
  if (n.endsWith('vh')) {
    return n;
  }
  if (n.endsWith('vw')) {
    return n;
  }
  if (n.endsWith('vmax')) {
    return n;
  }
  if (n.endsWith('vmin')) {
    return n;
  }
  return `${n}px`;
};

const getPixelSize = (
  size: undefined | string | number,
  parentSize: number,
  innerWidth: number,
  innerHeight: number,
) => {
  if (size && typeof size === 'string') {
    if (size.endsWith('px')) {
      return Number(size.replace('px', ''));
    }
    if (size.endsWith('%')) {
      const ratio = Number(size.replace('%', '')) / 100;
      return parentSize * ratio;
    }
    if (size.endsWith('vw')) {
      const ratio = Number(size.replace('vw', '')) / 100;
      return innerWidth * ratio;
    }
    if (size.endsWith('vh')) {
      const ratio = Number(size.replace('vh', '')) / 100;
      return innerHeight * ratio;
    }
  }
  return size;
};

const calculateNewMax = (
  parentSize: { width: number; height: number },
  innerWidth: number,
  innerHeight: number,
  maxWidth?: string | number,
  maxHeight?: string | number,
  minWidth?: string | number,
  minHeight?: string | number,
) => {
  maxWidth = getPixelSize(maxWidth, parentSize.width, innerWidth, innerHeight);
  maxHeight = getPixelSize(maxHeight, parentSize.height, innerWidth, innerHeight);
  minWidth = getPixelSize(minWidth, parentSize.width, innerWidth, innerHeight);
  minHeight = getPixelSize(minHeight, parentSize.height, innerWidth, innerHeight);
  return {
    maxWidth: typeof maxWidth === 'undefined' ? undefined : Number(maxWidth),
    maxHeight: typeof maxHeight === 'undefined' ? undefined : Number(maxHeight),
    minWidth: typeof minWidth === 'undefined' ? undefined : Number(minWidth),
    minHeight: typeof minHeight === 'undefined' ? undefined : Number(minHeight),
  };
};

/**
 * transform T | [T, T] to [T, T]
 * @param val
 * @returns
 */
// tslint:disable-next-line
const normalizeToPair = <T,>(val: T | [T, T]): [T, T] => (Array.isArray(val) ? val : [val, val]);

const definedProps = [
  'as',
  'ref',
  'style',
  'className',
  'grid',
  'snap',
  'bounds',
  'boundsByDirection',
  'size',
  'defaultSize',
  'minWidth',
  'minHeight',
  'maxWidth',
  'maxHeight',
  'lockAspectRatio',
  'lockAspectRatioExtraWidth',
  'lockAspectRatioExtraHeight',
  'enable',
  'handleStyles',
  'handleClasses',
  'handleWrapperStyle',
  'handleWrapperClass',
  'children',
  'onResizeStart',
  'onResize',
  'onResizeStop',
  'handleComponent',
  'scale',
  'resizeRatio',
  'snapGap',
];

// HACK: This class is used to calculate % size.
const baseClassName = '__resizable_base__';

declare global {
  interface Window {
    MouseEvent: typeof MouseEvent;
    TouchEvent: typeof TouchEvent;
  }
}

interface NewSize {
  newHeight: number | string;
  newWidth: number | string;
}
export class Resizable extends React.PureComponent<ResizableProps, State> {
  flexDir?: 'row' | 'column';

  get parentNode(): HTMLElement | null {
    if (!this.resizable) {
      return null;
    }
    return this.resizable.parentNode as HTMLElement;
  }

  get window(): Window | null {
    if (!this.resizable) {
      return null;
    }
    if (!this.resizable.ownerDocument) {
      return null;
    }
    return this.resizable.ownerDocument.defaultView as Window;
  }

  get propsSize(): Size {
    return this.props.size || this.props.defaultSize || DEFAULT_SIZE;
  }

  get size(): NumberSize {
    let width = 0;
    let height = 0;
    if (this.resizable && this.window) {
      const orgWidth = this.resizable.offsetWidth;
      const orgHeight = this.resizable.offsetHeight;
      // HACK: Set position `relative` to get parent size.
      //       This is because when re-resizable set `absolute`, I can not get base width correctly.
      const orgPosition = this.resizable.style.position;
      if (orgPosition !== 'relative') {
        this.resizable.style.position = 'relative';
      }
      // INFO: Use original width or height if set auto.
      width = this.resizable.style.width !== 'auto' ? this.resizable.offsetWidth : orgWidth;
      height = this.resizable.style.height !== 'auto' ? this.resizable.offsetHeight : orgHeight;
      // Restore original position
      this.resizable.style.position = orgPosition;
    }
    return { width, height };
  }

  get sizeStyle(): { width: string; height: string } {
    const { size } = this.props;
    const getSize = (key: 'width' | 'height'): string => {
      if (typeof this.state[key] === 'undefined' || this.state[key] === 'auto') {
        return 'auto';
      }
      if (this.propsSize && this.propsSize[key] && this.propsSize[key]?.toString().endsWith('%')) {
        if (this.state[key].toString().endsWith('%')) {
          return this.state[key].toString();
        }
        const parentSize = this.getParentSize();
        const value = Number(this.state[key].toString().replace('px', ''));
        const percent = (value / parentSize[key]) * 100;
        return `${percent}%`;
      }
      return getStringSize(this.state[key]);
    };
    const width =
      size && typeof size.width !== 'undefined' && !this.state.isResizing
        ? getStringSize(size.width)
        : getSize('width');
    const height =
      size && typeof size.height !== 'undefined' && !this.state.isResizing
        ? getStringSize(size.height)
        : getSize('height');
    return { width, height };
  }

  public static defaultProps = {
    as: 'div',
    onResizeStart: () => {},
    onResize: () => {},
    onResizeStop: () => {},
    enable: {
      top: true,
      right: true,
      bottom: true,
      left: true,
      topRight: true,
      bottomRight: true,
      bottomLeft: true,
      topLeft: true,
    },
    style: {},
    grid: [1, 1],
    lockAspectRatio: false,
    lockAspectRatioExtraWidth: 0,
    lockAspectRatioExtraHeight: 0,
    scale: 1,
    resizeRatio: 1,
    snapGap: 0,
  };
  ratio = 1;
  resizable: HTMLElement | null = null;
  // For parent boundary
  parentLeft = 0;
  parentTop = 0;
  // For boundary
  resizableLeft = 0;
  resizableRight = 0;
  resizableTop = 0;
  resizableBottom = 0;
  // For target boundary
  targetLeft = 0;
  targetTop = 0;

  constructor(props: ResizableProps) {
    super(props);
    this.state = {
      isResizing: false,
      width: this.propsSize?.width ?? 'auto',
      height: this.propsSize?.height ?? 'auto',
      direction: 'right',
      original: {
        x: 0,
        y: 0,
        width: 0,
        height: 0,
      },
      backgroundStyle: {
        height: '100%',
        width: '100%',
        backgroundColor: 'rgba(0,0,0,0)',
        cursor: 'auto',
        opacity: 0,
        position: 'fixed',
        zIndex: 9999,
        top: '0',
        left: '0',
        bottom: '0',
        right: '0',
      },
      flexBasis: undefined,
    };

    this.onResizeStart = this.onResizeStart.bind(this);
    this.onMouseMove = this.onMouseMove.bind(this);
    this.onMouseUp = this.onMouseUp.bind(this);
  }

  getParentSize(): { width: number; height: number } {
    if (!this.parentNode) {
      if (!this.window) {
        return { width: 0, height: 0 };
      }
      return { width: this.window.innerWidth, height: this.window.innerHeight };
    }
    const base = this.appendBase();
    if (!base) {
      return { width: 0, height: 0 };
    }
    // INFO: To calculate parent width with flex layout
    let wrapChanged = false;
    const wrap = this.parentNode.style.flexWrap;
    if (wrap !== 'wrap') {
      wrapChanged = true;
      this.parentNode.style.flexWrap = 'wrap';
      // HACK: Use relative to get parent padding size
    }
    base.style.position = 'relative';
    base.style.minWidth = '100%';
    base.style.minHeight = '100%';
    const size = {
      width: base.offsetWidth,
      height: base.offsetHeight,
    };
    if (wrapChanged) {
      this.parentNode.style.flexWrap = wrap;
    }
    this.removeBase(base);
    return size;
  }

  bindEvents() {
    if (this.window) {
      this.window.addEventListener('mouseup', this.onMouseUp);
      this.window.addEventListener('mousemove', this.onMouseMove);
      this.window.addEventListener('mouseleave', this.onMouseUp);
      this.window.addEventListener('touchmove', this.onMouseMove, {
        capture: true,
        passive: false,
      });
      this.window.addEventListener('touchend', this.onMouseUp);
    }
  }

  unbindEvents() {
    if (this.window) {
      this.window.removeEventListener('mouseup', this.onMouseUp);
      this.window.removeEventListener('mousemove', this.onMouseMove);
      this.window.removeEventListener('mouseleave', this.onMouseUp);
      this.window.removeEventListener('touchmove', this.onMouseMove, true);
      this.window.removeEventListener('touchend', this.onMouseUp);
    }
  }

  componentDidMount() {
    if (!this.resizable || !this.window) {
      return;
    }
    const computedStyle = this.window.getComputedStyle(this.resizable);
    this.setState({
      width: this.state.width || this.size.width,
      height: this.state.height || this.size.height,
      flexBasis: computedStyle.flexBasis !== 'auto' ? computedStyle.flexBasis : undefined,
    });
  }

  appendBase = () => {
    if (!this.resizable || !this.window) {
      return null;
    }
    const parent = this.parentNode;
    if (!parent) {
      return null;
    }
    const element = this.window.document.createElement('div');
    element.style.width = '100%';
    element.style.height = '100%';
    element.style.position = 'absolute';
    element.style.transform = 'scale(0, 0)';
    element.style.left = '0';
    element.style.flex = '0 0 100%';
    if (element.classList) {
      element.classList.add(baseClassName);
    } else {
      element.className += baseClassName;
    }
    parent.appendChild(element);
    return element;
  };

  removeBase = (base: HTMLElement) => {
    const parent = this.parentNode;
    if (!parent) {
      return;
    }
    parent.removeChild(base);
  };

  componentWillUnmount() {
    if (this.window) {
      this.unbindEvents();
    }
  }

  createSizeForCssProperty(newSize: number | string, kind: 'width' | 'height'): number | string {
    const propsSize = this.propsSize && this.propsSize[kind];
    return this.state[kind] === 'auto' &&
      this.state.original[kind] === newSize &&
      (typeof propsSize === 'undefined' || propsSize === 'auto')
      ? 'auto'
      : newSize;
  }

  calculateNewMaxFromBoundary(maxWidth?: number, maxHeight?: number) {
    const { boundsByDirection } = this.props;
    const { direction } = this.state;
    const widthByDirection = boundsByDirection && hasDirection('left', direction);
    const heightByDirection = boundsByDirection && hasDirection('top', direction);
    let boundWidth;
    let boundHeight;
    if (this.props.bounds === 'parent') {
      const parent = this.parentNode;
      if (parent) {
        boundWidth = widthByDirection
          ? this.resizableRight - this.parentLeft
          : parent.offsetWidth + (this.parentLeft - this.resizableLeft);
        boundHeight = heightByDirection
          ? this.resizableBottom - this.parentTop
          : parent.offsetHeight + (this.parentTop - this.resizableTop);
      }
    } else if (this.props.bounds === 'window') {
      if (this.window) {
        boundWidth = widthByDirection ? this.resizableRight : this.window.innerWidth - this.resizableLeft;
        boundHeight = heightByDirection ? this.resizableBottom : this.window.innerHeight - this.resizableTop;
      }
    } else if (this.props.bounds) {
      boundWidth = widthByDirection
        ? this.resizableRight - this.targetLeft
        : this.props.bounds.offsetWidth + (this.targetLeft - this.resizableLeft);
      boundHeight = heightByDirection
        ? this.resizableBottom - this.targetTop
        : this.props.bounds.offsetHeight + (this.targetTop - this.resizableTop);
    }
    if (boundWidth && Number.isFinite(boundWidth)) {
      maxWidth = maxWidth && maxWidth < boundWidth ? maxWidth : boundWidth;
    }
    if (boundHeight && Number.isFinite(boundHeight)) {
      maxHeight = maxHeight && maxHeight < boundHeight ? maxHeight : boundHeight;
    }
    return { maxWidth, maxHeight };
  }

  calculateNewSizeFromDirection(clientX: number, clientY: number) {
    const scale = this.props.scale || 1;
    const [resizeRatioX, resizeRatioY] = normalizeToPair(this.props.resizeRatio || 1);
    const { direction, original } = this.state;
    const { lockAspectRatio, lockAspectRatioExtraHeight, lockAspectRatioExtraWidth } = this.props;
    let newWidth = original.width;
    let newHeight = original.height;
    const extraHeight = lockAspectRatioExtraHeight || 0;
    const extraWidth = lockAspectRatioExtraWidth || 0;
    if (hasDirection('right', direction)) {
      newWidth = original.width + ((clientX - original.x) * resizeRatioX) / scale;
      if (lockAspectRatio) {
        newHeight = (newWidth - extraWidth) / this.ratio + extraHeight;
      }
    }
    if (hasDirection('left', direction)) {
      newWidth = original.width - ((clientX - original.x) * resizeRatioX) / scale;
      if (lockAspectRatio) {
        newHeight = (newWidth - extraWidth) / this.ratio + extraHeight;
      }
    }
    if (hasDirection('bottom', direction)) {
      newHeight = original.height + ((clientY - original.y) * resizeRatioY) / scale;
      if (lockAspectRatio) {
        newWidth = (newHeight - extraHeight) * this.ratio + extraWidth;
      }
    }
    if (hasDirection('top', direction)) {
      newHeight = original.height - ((clientY - original.y) * resizeRatioY) / scale;
      if (lockAspectRatio) {
        newWidth = (newHeight - extraHeight) * this.ratio + extraWidth;
      }
    }
    return { newWidth, newHeight };
  }

  calculateNewSizeFromAspectRatio(
    newWidth: number,
    newHeight: number,
    max: { width?: number; height?: number },
    min: { width?: number; height?: number },
  ) {
    const { lockAspectRatio, lockAspectRatioExtraHeight, lockAspectRatioExtraWidth } = this.props;
    const computedMinWidth = typeof min.width === 'undefined' ? 10 : min.width;
    const computedMaxWidth = typeof max.width === 'undefined' || max.width < 0 ? newWidth : max.width;
    const computedMinHeight = typeof min.height === 'undefined' ? 10 : min.height;
    const computedMaxHeight = typeof max.height === 'undefined' || max.height < 0 ? newHeight : max.height;
    const extraHeight = lockAspectRatioExtraHeight || 0;
    const extraWidth = lockAspectRatioExtraWidth || 0;
    if (lockAspectRatio) {
      const extraMinWidth = (computedMinHeight - extraHeight) * this.ratio + extraWidth;
      const extraMaxWidth = (computedMaxHeight - extraHeight) * this.ratio + extraWidth;
      const extraMinHeight = (computedMinWidth - extraWidth) / this.ratio + extraHeight;
      const extraMaxHeight = (computedMaxWidth - extraWidth) / this.ratio + extraHeight;
      const lockedMinWidth = Math.max(computedMinWidth, extraMinWidth);
      const lockedMaxWidth = Math.min(computedMaxWidth, extraMaxWidth);
      const lockedMinHeight = Math.max(computedMinHeight, extraMinHeight);
      const lockedMaxHeight = Math.min(computedMaxHeight, extraMaxHeight);
      newWidth = clamp(newWidth, lockedMinWidth, lockedMaxWidth);
      newHeight = clamp(newHeight, lockedMinHeight, lockedMaxHeight);
    } else {
      newWidth = clamp(newWidth, computedMinWidth, computedMaxWidth);
      newHeight = clamp(newHeight, computedMinHeight, computedMaxHeight);
    }
    return { newWidth, newHeight };
  }

  setBoundingClientRect() {
    // For parent boundary
    if (this.props.bounds === 'parent') {
      const parent = this.parentNode;
      if (parent) {
        const parentRect = parent.getBoundingClientRect();
        this.parentLeft = parentRect.left;
        this.parentTop = parentRect.top;
      }
    }

    // For target(html element) boundary
    if (this.props.bounds && typeof this.props.bounds !== 'string') {
      const targetRect = this.props.bounds.getBoundingClientRect();
      this.targetLeft = targetRect.left;
      this.targetTop = targetRect.top;
    }

    // For boundary
    if (this.resizable) {
      const { left, top, right, bottom } = this.resizable.getBoundingClientRect();
      this.resizableLeft = left;
      this.resizableRight = right;
      this.resizableTop = top;
      this.resizableBottom = bottom;
    }
  }

  onResizeStart(event: React.MouseEvent<HTMLElement> | React.TouchEvent<HTMLElement>, direction: Direction) {
    if (!this.resizable || !this.window) {
      return;
    }
    let clientX = 0;
    let clientY = 0;
    if (event.nativeEvent && isMouseEvent(event.nativeEvent)) {
      clientX = event.nativeEvent.clientX;
      clientY = event.nativeEvent.clientY;
    } else if (event.nativeEvent && isTouchEvent(event.nativeEvent)) {
      clientX = (event.nativeEvent as TouchEvent).touches[0].clientX;
      clientY = (event.nativeEvent as TouchEvent).touches[0].clientY;
    }
    if (this.props.onResizeStart) {
      if (this.resizable) {
        const startResize = this.props.onResizeStart(event, direction, this.resizable);
        if (startResize === false) {
          return;
        }
      }
    }

    // Fix #168
    if (this.props.size) {
      if (typeof this.props.size.height !== 'undefined' && this.props.size.height !== this.state.height) {
        this.setState({ height: this.props.size.height });
      }
      if (typeof this.props.size.width !== 'undefined' && this.props.size.width !== this.state.width) {
        this.setState({ width: this.props.size.width });
      }
    }

    // For lockAspectRatio case
    this.ratio =
      typeof this.props.lockAspectRatio === 'number' ? this.props.lockAspectRatio : this.size.width / this.size.height;

    let flexBasis;
    const computedStyle = this.window.getComputedStyle(this.resizable);
    if (computedStyle.flexBasis !== 'auto') {
      const parent = this.parentNode;
      if (parent) {
        const dir = this.window.getComputedStyle(parent).flexDirection;
        this.flexDir = dir.startsWith('row') ? 'row' : 'column';
        flexBasis = computedStyle.flexBasis;
      }
    }
    // For boundary
    this.setBoundingClientRect();
    this.bindEvents();
    const state = {
      original: {
        x: clientX,
        y: clientY,
        width: this.size.width,
        height: this.size.height,
      },
      isResizing: true,
      backgroundStyle: {
        ...this.state.backgroundStyle,
        cursor: this.window.getComputedStyle(event.target as HTMLElement).cursor || 'auto',
      },
      direction,
      flexBasis,
    };

    this.setState(state);
  }

  onMouseMove(event: MouseEvent | TouchEvent) {
    if (!this.state.isResizing || !this.resizable || !this.window) {
      return;
    }
    if (this.window.TouchEvent && isTouchEvent(event)) {
      try {
        event.preventDefault();
        event.stopPropagation();
      } catch (e) {
        // Ignore on fail
      }
    }
    let { maxWidth, maxHeight, minWidth, minHeight } = this.props;
    const clientX = isTouchEvent(event) ? event.touches[0].clientX : event.clientX;
    const clientY = isTouchEvent(event) ? event.touches[0].clientY : event.clientY;
    const { direction, original, width, height } = this.state;
    const parentSize = this.getParentSize();
    const max = calculateNewMax(
      parentSize,
      this.window.innerWidth,
      this.window.innerHeight,
      maxWidth,
      maxHeight,
      minWidth,
      minHeight,
    );

    maxWidth = max.maxWidth;
    maxHeight = max.maxHeight;
    minWidth = max.minWidth;
    minHeight = max.minHeight;

    // Calculate new size
    let { newHeight, newWidth }: NewSize = this.calculateNewSizeFromDirection(clientX, clientY);

    // Calculate max size from boundary settings
    const boundaryMax = this.calculateNewMaxFromBoundary(maxWidth, maxHeight);

    if (this.props.snap && this.props.snap.x) {
      newWidth = findClosestSnap(newWidth, this.props.snap.x, this.props.snapGap);
    }
    if (this.props.snap && this.props.snap.y) {
      newHeight = findClosestSnap(newHeight, this.props.snap.y, this.props.snapGap);
    }

    // Calculate new size from aspect ratio
    const newSize = this.calculateNewSizeFromAspectRatio(
      newWidth,
      newHeight,
      { width: boundaryMax.maxWidth, height: boundaryMax.maxHeight },
      { width: minWidth, height: minHeight },
    );
    newWidth = newSize.newWidth;
    newHeight = newSize.newHeight;

    if (this.props.grid) {
      const newGridWidth = snap(newWidth, this.props.grid[0]);
      const newGridHeight = snap(newHeight, this.props.grid[1]);
      const gap = this.props.snapGap || 0;
      const w = gap === 0 || Math.abs(newGridWidth - newWidth) <= gap ? newGridWidth : newWidth;
      const h = gap === 0 || Math.abs(newGridHeight - newHeight) <= gap ? newGridHeight : newHeight;
      newWidth = w;
      newHeight = h;
    }

    const delta = {
      width: newWidth - original.width,
      height: newHeight - original.height,
    };

    if (width && typeof width === 'string') {
      if (width.endsWith('%')) {
        const percent = (newWidth / parentSize.width) * 100;
        newWidth = `${percent}%`;
      } else if (width.endsWith('vw')) {
        const vw = (newWidth / this.window.innerWidth) * 100;
        newWidth = `${vw}vw`;
      } else if (width.endsWith('vh')) {
        const vh = (newWidth / this.window.innerHeight) * 100;
        newWidth = `${vh}vh`;
      }
    }

    if (height && typeof height === 'string') {
      if (height.endsWith('%')) {
        const percent = (newHeight / parentSize.height) * 100;
        newHeight = `${percent}%`;
      } else if (height.endsWith('vw')) {
        const vw = (newHeight / this.window.innerWidth) * 100;
        newHeight = `${vw}vw`;
      } else if (height.endsWith('vh')) {
        const vh = (newHeight / this.window.innerHeight) * 100;
        newHeight = `${vh}vh`;
      }
    }

    const newState: { width: string | number; height: string | number; flexBasis?: string | number } = {
      width: this.createSizeForCssProperty(newWidth, 'width'),
      height: this.createSizeForCssProperty(newHeight, 'height'),
    };

    if (this.flexDir === 'row') {
      newState.flexBasis = newState.width;
    } else if (this.flexDir === 'column') {
      newState.flexBasis = newState.height;
    }

    const widthChanged = this.state.width !== newState.width;
    const heightChanged = this.state.height !== newState.height;
    const flexBaseChanged = this.state.flexBasis !== newState.flexBasis;
    const changed = widthChanged || heightChanged || flexBaseChanged;

    if (changed) {
      // For v18, update state sync
      flushSync(() => {
        this.setState(newState);
      });
    }

    if (this.props.onResize) {
      if (changed) {
        this.props.onResize(event, direction, this.resizable, delta);
      }
    }
  }

  onMouseUp(event: MouseEvent | TouchEvent) {
    const { isResizing, direction, original } = this.state;
    if (!isResizing || !this.resizable) {
      return;
    }
    const delta = {
      width: this.size.width - original.width,
      height: this.size.height - original.height,
    };
    if (this.props.onResizeStop) {
      this.props.onResizeStop(event, direction, this.resizable, delta);
    }
    if (this.props.size) {
      this.setState({ width: this.props.size.width ?? 'auto', height: this.props.size.height ?? 'auto' });
    }
    this.unbindEvents();
    this.setState({
      isResizing: false,
      backgroundStyle: { ...this.state.backgroundStyle, cursor: 'auto' },
    });
  }

  updateSize(size: Size) {
    this.setState({ width: size.width ?? 'auto', height: size.height ?? 'auto' });
  }

  renderResizer() {
    const { enable, handleStyles, handleClasses, handleWrapperStyle, handleWrapperClass, handleComponent } = this.props;
    if (!enable) {
      return null;
    }
    const resizers = Object.keys(enable).map(dir => {
      if (enable[dir as Direction] !== false) {
        return (
          <Resizer
            key={dir}
            direction={dir as Direction}
            onResizeStart={this.onResizeStart}
            replaceStyles={handleStyles && handleStyles[dir as Direction]}
            className={handleClasses && handleClasses[dir as Direction]}
          >
            {handleComponent && handleComponent[dir as Direction] ? handleComponent[dir as Direction] : null}
          </Resizer>
        );
      }
      return null;
    });
    // #93 Wrap the resize box in span (will not break 100% width/height)
    return (
      <div className={handleWrapperClass} style={handleWrapperStyle}>
        {resizers}
      </div>
    );
  }

  render() {
    const extendsProps = Object.keys(this.props).reduce((acc, key) => {
      if (definedProps.indexOf(key) !== -1) {
        return acc;
      }
      acc[key] = this.props[key as keyof ResizableProps];
      return acc;
    }, {} as { [key: string]: any });

    const style: React.CSSProperties = {
      position: 'relative',
      userSelect: this.state.isResizing ? 'none' : 'auto',
      ...this.props.style,
      ...this.sizeStyle,
      maxWidth: this.props.maxWidth,
      maxHeight: this.props.maxHeight,
      minWidth: this.props.minWidth,
      minHeight: this.props.minHeight,
      boxSizing: 'border-box',
      flexShrink: 0,
    };

    if (this.state.flexBasis) {
      style.flexBasis = this.state.flexBasis;
    }

    const Wrapper = this.props.as || 'div';

    return (
      <Wrapper
        style={style}
        className={this.props.className}
        {...extendsProps}
        // `ref` is after `extendsProps` to ensure this one wins over a version
        // passed in
        ref={(c: HTMLElement | null) => {
          if (c) {
            this.resizable = c;
          }
        }}
      >
        {this.state.isResizing && <div style={this.state.backgroundStyle} />}
        {this.props.children}
        {this.renderResizer()}
      </Wrapper>
    );
  }
}