File

projects/angular-resizable-element/src/lib/resizable.directive.ts

Description

Place this on an element to make it resizable. For example:

<div
  mwlResizable
  [resizeEdges]="{bottom: true, right: true, top: true, left: true}"
  [enableGhostResize]="true">
</div>

Or in case they are sibling elements:

<div mwlResizable #resizableElement="mwlResizable"></div>
<div mwlResizeHandle [resizableContainer]="resizableElement" [resizeEdges]="{bottom: true, right: true}"></div>

Implements

OnInit OnDestroy

Metadata

Selector [mwlResizable]

Index

Inputs
Outputs

Inputs

allowNegativeResizes
Type : boolean
Default value : false

Allow elements to be resized to negative dimensions

enableGhostResize
Type : boolean
Default value : false

Set to true to enable a temporary resizing effect of the element in between the resizeStart and resizeEnd events.

ghostElementPositioning
Type : "fixed" | "absolute"
Default value : 'fixed'

Define the positioning of the ghost element (can be fixed or absolute)

mouseMoveThrottleMS
Type : number
Default value : MOUSE_MOVE_THROTTLE_MS

The mouse move throttle in milliseconds, default: 50 ms

resizeCursors
Type : ResizeCursors
Default value : DEFAULT_RESIZE_CURSORS

The mouse cursors that will be set on the resize edges

resizeSnapGrid
Type : Edges
Default value : {}

A snap grid that resize events will be locked to.

e.g. to only allow the element to be resized every 10px set it to {left: 10, right: 10}

validateResize
Type : function

A function that will be called before each resize event. Return true to allow the resize event to propagate or false to cancel it

Outputs

resizeEnd
Type : EventEmitter

Called after the mouse is released after a resize event. $event is a ResizeEvent object.

resizeStart
Type : EventEmitter

Called when the mouse is pressed and a resize event is about to begin. $event is a ResizeEvent object.

resizing
Type : EventEmitter

Called as the mouse is dragged after a resize event has begun. $event is a ResizeEvent object.

import {
  Directive,
  Renderer2,
  ElementRef,
  OnInit,
  Output,
  Input,
  EventEmitter,
  OnDestroy,
  NgZone,
  Inject,
  PLATFORM_ID,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Subject, Observable, Observer, merge } from 'rxjs';
import {
  map,
  mergeMap,
  takeUntil,
  filter,
  pairwise,
  take,
  share,
  tap,
} from 'rxjs/operators';
import { Edges } from './interfaces/edges.interface';
import { BoundingRectangle } from './interfaces/bounding-rectangle.interface';
import { ResizeEvent } from './interfaces/resize-event.interface';
import { IS_TOUCH_DEVICE } from './util/is-touch-device';
import { deepCloneNode } from './util/clone-node';

interface PointerEventCoordinate {
  clientX: number;
  clientY: number;
  event: MouseEvent | TouchEvent;
}

interface Coordinate {
  x: number;
  y: number;
}

function getNewBoundingRectangle(
  startingRect: BoundingRectangle,
  edges: Edges,
  clientX: number,
  clientY: number
): BoundingRectangle {
  const newBoundingRect: BoundingRectangle = {
    top: startingRect.top,
    bottom: startingRect.bottom,
    left: startingRect.left,
    right: startingRect.right,
  };

  if (edges.top) {
    newBoundingRect.top += clientY;
  }
  if (edges.bottom) {
    newBoundingRect.bottom += clientY;
  }
  if (edges.left) {
    newBoundingRect.left += clientX;
  }
  if (edges.right) {
    newBoundingRect.right += clientX;
  }
  newBoundingRect.height = newBoundingRect.bottom - newBoundingRect.top;
  newBoundingRect.width = newBoundingRect.right - newBoundingRect.left;

  return newBoundingRect;
}

function getElementRect(
  element: ElementRef,
  ghostElementPositioning: string
): BoundingRectangle {
  let translateX = 0;
  let translateY = 0;
  const style = element.nativeElement.style;
  const transformProperties = [
    'transform',
    '-ms-transform',
    '-moz-transform',
    '-o-transform',
  ];
  const transform = transformProperties
    .map((property) => style[property])
    .find((value) => !!value);
  if (transform && transform.includes('translate')) {
    translateX = transform.replace(
      /.*translate3?d?\((-?[0-9]*)px, (-?[0-9]*)px.*/,
      '$1'
    );
    translateY = transform.replace(
      /.*translate3?d?\((-?[0-9]*)px, (-?[0-9]*)px.*/,
      '$2'
    );
  }

  if (ghostElementPositioning === 'absolute') {
    return {
      height: element.nativeElement.offsetHeight,
      width: element.nativeElement.offsetWidth,
      top: element.nativeElement.offsetTop - translateY,
      bottom:
        element.nativeElement.offsetHeight +
        element.nativeElement.offsetTop -
        translateY,
      left: element.nativeElement.offsetLeft - translateX,
      right:
        element.nativeElement.offsetWidth +
        element.nativeElement.offsetLeft -
        translateX,
    };
  } else {
    const boundingRect: BoundingRectangle =
      element.nativeElement.getBoundingClientRect();
    return {
      height: boundingRect.height,
      width: boundingRect.width,
      top: boundingRect.top - translateY,
      bottom: boundingRect.bottom - translateY,
      left: boundingRect.left - translateX,
      right: boundingRect.right - translateX,
      scrollTop: element.nativeElement.scrollTop,
      scrollLeft: element.nativeElement.scrollLeft,
    };
  }
}

export interface ResizeCursors {
  topLeft: string;
  topRight: string;
  bottomLeft: string;
  bottomRight: string;
  leftOrRight: string;
  topOrBottom: string;
}

const DEFAULT_RESIZE_CURSORS: ResizeCursors = Object.freeze({
  topLeft: 'nw-resize',
  topRight: 'ne-resize',
  bottomLeft: 'sw-resize',
  bottomRight: 'se-resize',
  leftOrRight: 'col-resize',
  topOrBottom: 'row-resize',
});

function getResizeCursor(edges: Edges, cursors: ResizeCursors): string {
  if (edges.left && edges.top) {
    return cursors.topLeft;
  } else if (edges.right && edges.top) {
    return cursors.topRight;
  } else if (edges.left && edges.bottom) {
    return cursors.bottomLeft;
  } else if (edges.right && edges.bottom) {
    return cursors.bottomRight;
  } else if (edges.left || edges.right) {
    return cursors.leftOrRight;
  } else if (edges.top || edges.bottom) {
    return cursors.topOrBottom;
  } else {
    return '';
  }
}

function getEdgesDiff({
  edges,
  initialRectangle,
  newRectangle,
}: {
  edges: Edges;
  initialRectangle: BoundingRectangle;
  newRectangle: BoundingRectangle;
}): Edges {
  const edgesDiff: Edges = {};
  Object.keys(edges).forEach((edge) => {
    edgesDiff[edge] = (newRectangle[edge] || 0) - (initialRectangle[edge] || 0);
  });
  return edgesDiff;
}

const RESIZE_ACTIVE_CLASS: string = 'resize-active';
const RESIZE_GHOST_ELEMENT_CLASS: string = 'resize-ghost-element';

export const MOUSE_MOVE_THROTTLE_MS: number = 50;

/**
 * Place this on an element to make it resizable. For example:
 *
 * ```html
 * <div
 *   mwlResizable
 *   [resizeEdges]="{bottom: true, right: true, top: true, left: true}"
 *   [enableGhostResize]="true">
 * </div>
 * ```
 * Or in case they are sibling elements:
 * ```html
 * <div mwlResizable #resizableElement="mwlResizable"></div>
 * <div mwlResizeHandle [resizableContainer]="resizableElement" [resizeEdges]="{bottom: true, right: true}"></div>
 * ```
 */
@Directive({
  selector: '[mwlResizable]',
  exportAs: 'mwlResizable',
})
export class ResizableDirective implements OnInit, OnDestroy {
  /**
   * A function that will be called before each resize event. Return `true` to allow the resize event to propagate or `false` to cancel it
   */
  @Input() validateResize: (resizeEvent: ResizeEvent) => boolean;

  /**
   * Set to `true` to enable a temporary resizing effect of the element in between the `resizeStart` and `resizeEnd` events.
   */
  @Input() enableGhostResize: boolean = false;

  /**
   * A snap grid that resize events will be locked to.
   *
   * e.g. to only allow the element to be resized every 10px set it to `{left: 10, right: 10}`
   */
  @Input() resizeSnapGrid: Edges = {};

  /**
   * The mouse cursors that will be set on the resize edges
   */
  @Input() resizeCursors: ResizeCursors = DEFAULT_RESIZE_CURSORS;

  /**
   * Define the positioning of the ghost element (can be fixed or absolute)
   */
  @Input() ghostElementPositioning: 'fixed' | 'absolute' = 'fixed';

  /**
   * Allow elements to be resized to negative dimensions
   */
  @Input() allowNegativeResizes: boolean = false;

  /**
   * The mouse move throttle in milliseconds, default: 50 ms
   */
  @Input() mouseMoveThrottleMS: number = MOUSE_MOVE_THROTTLE_MS;

  /**
   * Called when the mouse is pressed and a resize event is about to begin. `$event` is a `ResizeEvent` object.
   */
  @Output() resizeStart = new EventEmitter<ResizeEvent>();

  /**
   * Called as the mouse is dragged after a resize event has begun. `$event` is a `ResizeEvent` object.
   */
  @Output() resizing = new EventEmitter<ResizeEvent>();

  /**
   * Called after the mouse is released after a resize event. `$event` is a `ResizeEvent` object.
   */
  @Output() resizeEnd = new EventEmitter<ResizeEvent>();

  /**
   * @hidden
   */
  public mouseup = new Subject<{
    clientX: number;
    clientY: number;
    edges?: Edges;
  }>();

  /**
   * @hidden
   */
  public mousedown = new Subject<{
    clientX: number;
    clientY: number;
    edges?: Edges;
  }>();

  /**
   * @hidden
   */
  public mousemove = new Subject<{
    clientX: number;
    clientY: number;
    edges?: Edges;
    event: MouseEvent | TouchEvent;
  }>();

  private pointerEventListeners: PointerEventListeners;

  private destroy$ = new Subject<void>();

  /**
   * @hidden
   */
  constructor(
    @Inject(PLATFORM_ID) private platformId: any,
    private renderer: Renderer2,
    public elm: ElementRef,
    private zone: NgZone
  ) {
    this.pointerEventListeners = PointerEventListeners.getInstance(
      renderer,
      zone
    );
  }

  /**
   * @hidden
   */
  ngOnInit(): void {
    const mousedown$: Observable<{
      clientX: number;
      clientY: number;
      edges?: Edges;
    }> = merge(this.pointerEventListeners.pointerDown, this.mousedown);

    const mousemove$ = merge(
      this.pointerEventListeners.pointerMove,
      this.mousemove
    ).pipe(
      tap(({ event }) => {
        if (currentResize) {
          try {
            event.preventDefault();
          } catch (e) {
            // just adding try-catch not to see errors in console if there is a passive listener for same event somewhere
            // browser does nothing except of writing errors to console
          }
        }
      }),
      share()
    );

    const mouseup$ = merge(this.pointerEventListeners.pointerUp, this.mouseup);

    let currentResize: {
      edges: Edges;
      startingRect: BoundingRectangle;
      currentRect: BoundingRectangle;
      clonedNode?: HTMLElement;
    } | null;

    const removeGhostElement = () => {
      if (currentResize && currentResize.clonedNode) {
        this.elm.nativeElement.parentElement.removeChild(
          currentResize.clonedNode
        );
        this.renderer.setStyle(this.elm.nativeElement, 'visibility', 'inherit');
      }
    };

    const getResizeCursors = (): ResizeCursors => {
      return {
        ...DEFAULT_RESIZE_CURSORS,
        ...this.resizeCursors,
      };
    };

    const mousedrag: Observable<any> = mousedown$
      .pipe(
        mergeMap((startCoords) => {
          function getDiff(moveCoords: { clientX: number; clientY: number }) {
            return {
              clientX: moveCoords.clientX - startCoords.clientX,
              clientY: moveCoords.clientY - startCoords.clientY,
            };
          }

          const getSnapGrid = () => {
            const snapGrid: Coordinate = { x: 1, y: 1 };

            if (currentResize) {
              if (this.resizeSnapGrid.left && currentResize.edges.left) {
                snapGrid.x = +this.resizeSnapGrid.left;
              } else if (
                this.resizeSnapGrid.right &&
                currentResize.edges.right
              ) {
                snapGrid.x = +this.resizeSnapGrid.right;
              }

              if (this.resizeSnapGrid.top && currentResize.edges.top) {
                snapGrid.y = +this.resizeSnapGrid.top;
              } else if (
                this.resizeSnapGrid.bottom &&
                currentResize.edges.bottom
              ) {
                snapGrid.y = +this.resizeSnapGrid.bottom;
              }
            }

            return snapGrid;
          };

          function getGrid(
            coords: { clientX: number; clientY: number },
            snapGrid: Coordinate
          ) {
            return {
              x: Math.ceil(coords.clientX / snapGrid.x),
              y: Math.ceil(coords.clientY / snapGrid.y),
            };
          }

          return (
            merge(
              mousemove$.pipe(take(1)).pipe(map((coords) => [, coords])),
              mousemove$.pipe(pairwise())
            ) as Observable<
              [
                { clientX: number; clientY: number },
                { clientX: number; clientY: number }
              ]
            >
          )
            .pipe(
              map(([previousCoords, newCoords]) => {
                return [
                  previousCoords ? getDiff(previousCoords) : previousCoords,
                  getDiff(newCoords),
                ];
              })
            )
            .pipe(
              filter(([previousCoords, newCoords]) => {
                if (!previousCoords) {
                  return true;
                }

                const snapGrid: Coordinate = getSnapGrid();
                const previousGrid: Coordinate = getGrid(
                  previousCoords,
                  snapGrid
                );
                const newGrid: Coordinate = getGrid(newCoords, snapGrid);

                return (
                  previousGrid.x !== newGrid.x || previousGrid.y !== newGrid.y
                );
              })
            )
            .pipe(
              map(([, newCoords]) => {
                const snapGrid: Coordinate = getSnapGrid();
                return {
                  clientX:
                    Math.round(newCoords.clientX / snapGrid.x) * snapGrid.x,
                  clientY:
                    Math.round(newCoords.clientY / snapGrid.y) * snapGrid.y,
                };
              })
            )
            .pipe(takeUntil(merge(mouseup$, mousedown$)));
        })
      )
      .pipe(filter(() => !!currentResize));

    mousedrag
      .pipe(
        map(({ clientX, clientY }) => {
          return getNewBoundingRectangle(
            currentResize!.startingRect,
            currentResize!.edges,
            clientX,
            clientY
          );
        })
      )
      .pipe(
        filter((newBoundingRect: BoundingRectangle) => {
          return (
            this.allowNegativeResizes ||
            !!(
              newBoundingRect.height &&
              newBoundingRect.width &&
              newBoundingRect.height > 0 &&
              newBoundingRect.width > 0
            )
          );
        })
      )
      .pipe(
        filter((newBoundingRect: BoundingRectangle) => {
          return this.validateResize
            ? this.validateResize({
                rectangle: newBoundingRect,
                edges: getEdgesDiff({
                  edges: currentResize!.edges,
                  initialRectangle: currentResize!.startingRect,
                  newRectangle: newBoundingRect,
                }),
              })
            : true;
        }),
        takeUntil(this.destroy$)
      )
      .subscribe((newBoundingRect: BoundingRectangle) => {
        if (currentResize && currentResize.clonedNode) {
          this.renderer.setStyle(
            currentResize.clonedNode,
            'height',
            `${newBoundingRect.height}px`
          );
          this.renderer.setStyle(
            currentResize.clonedNode,
            'width',
            `${newBoundingRect.width}px`
          );
          this.renderer.setStyle(
            currentResize.clonedNode,
            'top',
            `${newBoundingRect.top}px`
          );
          this.renderer.setStyle(
            currentResize.clonedNode,
            'left',
            `${newBoundingRect.left}px`
          );
        }

        if (this.resizing.observers.length > 0) {
          this.zone.run(() => {
            this.resizing.emit({
              edges: getEdgesDiff({
                edges: currentResize!.edges,
                initialRectangle: currentResize!.startingRect,
                newRectangle: newBoundingRect,
              }),
              rectangle: newBoundingRect,
            });
          });
        }
        currentResize!.currentRect = newBoundingRect;
      });

    mousedown$
      .pipe(
        map(({ edges }) => {
          return edges || {};
        }),
        filter((edges: Edges) => {
          return Object.keys(edges).length > 0;
        }),
        takeUntil(this.destroy$)
      )
      .subscribe((edges: Edges) => {
        if (currentResize) {
          removeGhostElement();
        }
        const startingRect: BoundingRectangle = getElementRect(
          this.elm,
          this.ghostElementPositioning
        );
        currentResize = {
          edges,
          startingRect,
          currentRect: startingRect,
        };
        const resizeCursors = getResizeCursors();
        const cursor = getResizeCursor(currentResize.edges, resizeCursors);
        this.renderer.setStyle(document.body, 'cursor', cursor);
        this.setElementClass(this.elm, RESIZE_ACTIVE_CLASS, true);
        if (this.enableGhostResize) {
          currentResize.clonedNode = deepCloneNode(this.elm.nativeElement);
          this.elm.nativeElement.parentElement.appendChild(
            currentResize.clonedNode
          );
          this.renderer.setStyle(
            this.elm.nativeElement,
            'visibility',
            'hidden'
          );
          this.renderer.setStyle(
            currentResize.clonedNode,
            'position',
            this.ghostElementPositioning
          );
          this.renderer.setStyle(
            currentResize.clonedNode,
            'left',
            `${currentResize.startingRect.left}px`
          );
          this.renderer.setStyle(
            currentResize.clonedNode,
            'top',
            `${currentResize.startingRect.top}px`
          );
          this.renderer.setStyle(
            currentResize.clonedNode,
            'height',
            `${currentResize.startingRect.height}px`
          );
          this.renderer.setStyle(
            currentResize.clonedNode,
            'width',
            `${currentResize.startingRect.width}px`
          );
          this.renderer.setStyle(
            currentResize.clonedNode,
            'cursor',
            getResizeCursor(currentResize.edges, resizeCursors)
          );
          this.renderer.addClass(
            currentResize.clonedNode,
            RESIZE_GHOST_ELEMENT_CLASS
          );
          currentResize.clonedNode!.scrollTop = currentResize.startingRect
            .scrollTop as number;
          currentResize.clonedNode!.scrollLeft = currentResize.startingRect
            .scrollLeft as number;
        }
        if (this.resizeStart.observers.length > 0) {
          this.zone.run(() => {
            this.resizeStart.emit({
              edges: getEdgesDiff({
                edges,
                initialRectangle: startingRect,
                newRectangle: startingRect,
              }),
              rectangle: getNewBoundingRectangle(startingRect, {}, 0, 0),
            });
          });
        }
      });

    mouseup$.pipe(takeUntil(this.destroy$)).subscribe(() => {
      if (currentResize) {
        this.renderer.removeClass(this.elm.nativeElement, RESIZE_ACTIVE_CLASS);
        this.renderer.setStyle(document.body, 'cursor', '');
        this.renderer.setStyle(this.elm.nativeElement, 'cursor', '');
        if (this.resizeEnd.observers.length > 0) {
          this.zone.run(() => {
            this.resizeEnd.emit({
              edges: getEdgesDiff({
                edges: currentResize!.edges,
                initialRectangle: currentResize!.startingRect,
                newRectangle: currentResize!.currentRect,
              }),
              rectangle: currentResize!.currentRect,
            });
          });
        }
        removeGhostElement();
        currentResize = null;
      }
    });
  }

  /**
   * @hidden
   */
  ngOnDestroy(): void {
    // browser check for angular universal, because it doesn't know what document is
    if (isPlatformBrowser(this.platformId)) {
      this.renderer.setStyle(document.body, 'cursor', '');
    }
    this.mousedown.complete();
    this.mouseup.complete();
    this.mousemove.complete();
    this.destroy$.next();
  }

  private setElementClass(elm: ElementRef, name: string, add: boolean): void {
    if (add) {
      this.renderer.addClass(elm.nativeElement, name);
    } else {
      this.renderer.removeClass(elm.nativeElement, name);
    }
  }
}

class PointerEventListeners {
  public pointerDown: Observable<PointerEventCoordinate>;

  public pointerMove: Observable<PointerEventCoordinate>;

  public pointerUp: Observable<PointerEventCoordinate>;

  private static instance: PointerEventListeners;

  public static getInstance(
    renderer: Renderer2,
    zone: NgZone
  ): PointerEventListeners {
    if (!PointerEventListeners.instance) {
      PointerEventListeners.instance = new PointerEventListeners(
        renderer,
        zone
      );
    }
    return PointerEventListeners.instance;
  }

  constructor(renderer: Renderer2, zone: NgZone) {
    this.pointerDown = new Observable(
      (observer: Observer<PointerEventCoordinate>) => {
        let unsubscribeMouseDown: () => void;
        let unsubscribeTouchStart: (() => void) | undefined;

        zone.runOutsideAngular(() => {
          unsubscribeMouseDown = renderer.listen(
            'document',
            'mousedown',
            (event: MouseEvent) => {
              observer.next({
                clientX: event.clientX,
                clientY: event.clientY,
                event,
              });
            }
          );

          if (IS_TOUCH_DEVICE) {
            unsubscribeTouchStart = renderer.listen(
              'document',
              'touchstart',
              (event: TouchEvent) => {
                observer.next({
                  clientX: event.touches[0].clientX,
                  clientY: event.touches[0].clientY,
                  event,
                });
              }
            );
          }
        });

        return () => {
          unsubscribeMouseDown();
          if (IS_TOUCH_DEVICE) {
            unsubscribeTouchStart!();
          }
        };
      }
    ).pipe(share());

    this.pointerMove = new Observable(
      (observer: Observer<PointerEventCoordinate>) => {
        let unsubscribeMouseMove: () => void;
        let unsubscribeTouchMove: (() => void) | undefined;

        zone.runOutsideAngular(() => {
          unsubscribeMouseMove = renderer.listen(
            'document',
            'mousemove',
            (event: MouseEvent) => {
              observer.next({
                clientX: event.clientX,
                clientY: event.clientY,
                event,
              });
            }
          );

          if (IS_TOUCH_DEVICE) {
            unsubscribeTouchMove = renderer.listen(
              'document',
              'touchmove',
              (event: TouchEvent) => {
                observer.next({
                  clientX: event.targetTouches[0].clientX,
                  clientY: event.targetTouches[0].clientY,
                  event,
                });
              }
            );
          }
        });

        return () => {
          unsubscribeMouseMove();
          if (IS_TOUCH_DEVICE) {
            unsubscribeTouchMove!();
          }
        };
      }
    ).pipe(share());

    this.pointerUp = new Observable(
      (observer: Observer<PointerEventCoordinate>) => {
        let unsubscribeMouseUp: () => void;
        let unsubscribeTouchEnd: (() => void) | undefined;
        let unsubscribeTouchCancel: (() => void) | undefined;

        zone.runOutsideAngular(() => {
          unsubscribeMouseUp = renderer.listen(
            'document',
            'mouseup',
            (event: MouseEvent) => {
              observer.next({
                clientX: event.clientX,
                clientY: event.clientY,
                event,
              });
            }
          );

          if (IS_TOUCH_DEVICE) {
            unsubscribeTouchEnd = renderer.listen(
              'document',
              'touchend',
              (event: TouchEvent) => {
                observer.next({
                  clientX: event.changedTouches[0].clientX,
                  clientY: event.changedTouches[0].clientY,
                  event,
                });
              }
            );

            unsubscribeTouchCancel = renderer.listen(
              'document',
              'touchcancel',
              (event: TouchEvent) => {
                observer.next({
                  clientX: event.changedTouches[0].clientX,
                  clientY: event.changedTouches[0].clientY,
                  event,
                });
              }
            );
          }
        });

        return () => {
          unsubscribeMouseUp();
          if (IS_TOUCH_DEVICE) {
            unsubscribeTouchEnd!();
            unsubscribeTouchCancel!();
          }
        };
      }
    ).pipe(share());
  }
}

result-matching ""

    No results matching ""