Category
Programming/Engineering

Published
May 20, 2016

Read
2 min

Tweet
Share
Comment

Angular 2 Move and Nudgeable Directive

Article Purpose

The purpose of this article is to provide an Angular 2 attribute directive that empowers any HTML element to:

  • Move via drag
  • Move via arrow key nudge
  • Clamp its position to its parent's bounds

I creatively call it MoveAndNudgableDirective. Here are two samples of it in action:

MoveAndNudgableDirective Primitive Sample
MoveAndNudgableDirective Pablo Sample

Background

In experimenting with Angular 2 recently I re-created Buffer's image editing app Pablo (first example above). In working on the <canvas> editor portion I designed the MoveAndNudgableDirective directive. It allowed me to ensure both editable text components and custom graphic components (or any components for that matter) could easily be composed with the same behavior.

Though I stopped at my specific use case, this directive's code could be expanded upon and may be useful for others creating a wide range of web-based editor tooling. As a result I'm sharing it. Here are just a few ideas of its expansion:

  • Employ ctrl/cmd + click for multi-select and shared movement.
  • Employ shift + arrow keys for multiplied movement a la Photoshop's 10px nudge.
  • Leverage EventEmitter to trigger actions of all sorts. Here are some ideas:
    • When an element is clamped to an edge you could reveal a UI that allows alignment to its corners or direct center.
    • When an element comes in contact with another element they could impact each others movement.
    • When an element comes in contact with another element a subtle delay could trigger a sequence of depth sorting where continuing movement would "select" the current depth sort.
    • Get creative :)

Code

Below you'll find the code for this directive and below that you'll find the GitHub link for grabbing the example so you can download, manipulate, and interact with it yourself.


import { Directive, ElementRef, EventEmitter, Input, Output } from '@angular/core';

import * as _ from 'lodash';

@Directive({
    selector: '[move-and-nudgeable]',
    host: {
        '(mousedown)': 'onMouseDown($event)',
        '(document: mousemove)': 'onMouseMove($event)',
        '(document: mouseup)': 'onMouseUp($event)',
        '(keydown.ArrowUp)': 'onNudge($event)',
        '(keydown.ArrowRight)': 'onNudge($event)',
        '(keydown.ArrowDown)': 'onNudge($event)',
        '(keydown.ArrowLeft)': 'onNudge($event)',
        '(keyup.ArrowUp)': 'onNudge($event)',
        '(keyup.ArrowRight)': 'onNudge($event)',
        '(keyup.ArrowDown)': 'onNudge($event)',
        '(keyup.ArrowLeft)': 'onNudge($event)'
    }
})
export class MoveAndNudgeableDirective {

    //element refs
    private el: HTMLElement;
    private parent: HTMLElement;

    //movable flag
    private isMovable: boolean = false;
    
    //position model helper
    private pos: any = { x: 0, y: 0, clampX: 0, clampY: 0 };
    
    //arrow key store
    private keys: Array = [37, 38, 39, 40];

    constructor(el: ElementRef) {

        //el ref
        this.el = el.nativeElement;

        //tab index allows div to accept key events
        this.el.tabIndex = 1;

        //default key values
        this.keys[37] = this.keys[38] = this.keys[39] = this.keys[40] = 0;
    }

    public update() {

        //update ref due to ngIf
        this.parent = this.el.parentElement;

        //update clamp settings
        this.pos.clampX = parseInt(this.parent.style.width.replace('px', '')) - parseInt(this.el.style.width.replace('px', ''));
        this.pos.clampY = parseInt(this.parent.style.height.replace('px', '')) - parseInt(this.el.style.height.replace('px', ''));

        //update position
        this.updatePosition();
    }

    private updatePosition(x: number = 0, y: number = 0) {

        //update data
        this.pos.x += x;
        this.pos.y += y;

        //clamp data
        this.pos.x = _.clamp(this.pos.x, 0, this.pos.clampX);
        this.pos.y = _.clamp(this.pos.y, 0, this.pos.clampY);

        //update view
        this.el.style.left = this.pos.x + 'px';
        this.el.style.top = this.pos.y + 'px';
    }

    private onMouseDown($event) {

        //ensure fresh size calculation accounted for
        this.update();

        //update drag flag
        this.isMovable = true;
    }

    private onMouseMove($event) {
        
        //exit condition
        if (!this.isMovable) { return; }
        
        //update position
        this.updatePosition($event.movementX, $event.movementY);
    }

    private onMouseUp($event) {

        //update drag flag
        this.isMovable = false;
    }

    private onNudge($event) {
        
        //update keys
        this.keys[$event.keyCode] = $event.type === 'keydown' ? 1 : 0;

        //update move targets accommodating multiple keys
        let x = 0 - this.keys[37] + this.keys[39];
        let y = 0 - this.keys[38] + this.keys[40];

        //update position
        this.updatePosition(x, y);
    }

}

Hit me up on Twitter @derekknox if you have any thoughts or improvement ideas.

MoveAndNudgableDirective on Github