Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / HTML

Custom AngularJS Directives for File Browsing and Dropping

4.90/5 (13 votes)
4 Sep 2014CPOL7 min read 36.5K   579  
Describes how to create custom directives in AngularJS and give examples to enable model binding for file browsing and dropping

Introduction

In my work, I found AngularJS v1.2.16 could not bind model for <input type="file"/>. And it could not bind model for droppable <div> either. So I had to write two custom directives to enable these kinds of model binding via ngModel in order to avoid using jQuery.

The attachment contains the source code of the two directives and a demo indicating how to use the directives. You can extract and deploy it as a web application on IIS or any other web servers. Note the demo may not work properly if you directly open the HTML file in the web browser (with the file:/// protocol).

For more details and advanced usages, please refer to AngularJS's official web site. AngularJS is really a gigantic and powerful framework.

Custom Directive Basis

First let me briefly introduce how to write a custom directive. You could skip this chapter if you already know it.

We can define a custom directive in a module like the following pesudo-code:

JavaScript
angular.module('myMod', ['depMod', ...]).directive('myDirective', function (injected, ...) {
    return {
        restrict: ...,
        priority: ...,
        scope: ...,
        ......
    };
});

This custom directive name is 'myDirective' which is camel cased. Then we should use 'my-directive' as the name in HTML.

Note we can declare other modules that the custom directive relies on and inject services of those modules into the factory function. The factory function returns a setting object to tell AngularJS how to create the directive. The setting object can contain the following fields:

Field Example Description
restrict

'A'

'M'

'AEC'

Match the directive to Element ('E'), Attribute ('A'), Class ('C') or Comment ('M'). The default value is 'EA'.
replace true

True to replace the matched element by this directive. False to embed this directive into the matched element. The default value is false.

priority 10

Define the execution order if there are multiple custom directives in one element. High value means early execution. The default value is 0.

require

'?ngModel'

['^?myCalendar', 'myClock']

Search one or multiple named directive controllers in the same element level. Pass them to this directive's link function if found. Throw an error if not found. Each pass-in name can be prefixed by '^' or '?' or both. '^' means to search in all ancestors as well. '?' means not to throw an error if not found.

scope
JavaScript
true

{
  name: '@',
  age: '=newAge',
  select: '&onSelect'
}

False to use the existing scope which is the nearest outer controller's scope.

True to create a new scope which inherits from the nearest outer controller's scope. You can access all outer scoped objects in the new scope.

You can also use an object literal to create an isolated scope in which you cannot access any outer scoped objects. But you can still use $parent to access the outer scope.

The default value is false.

template

'<span ng-transclude><span>'

The inline template for this directive. It cannot be used together with templateUrl.

templateUrl

'myTpl.html'

The URL of the template for this directive. It cannot be used together with template.

transclude true

If it is true, the contents of the element matched by this directive will be moved into the element matched by ng-transclude inside this directive's template. The default value is false.

link

function(scope, element, attrs, ngModel) { }

Called when Angular is linking this directive to its scope. We can initialize scope and add event listeners here. It equals the post link function returned from compile.
compile

function(element, attrs, transclude) { }

Called when Angular is compiling the template to DOM. Scope is not available in compiling time. Optionally, we can return pre and post link functions.

controller

function($scope, $element, $attrs, $transclude) { }

A controller constructor to expose APIs to other directives. Another directive can inject this controller to its link function via require. This is the best way for different directives to communicate.

Let's talk further about some complex topics in the following sections:

  • Scope
  • Compile and link

Scope

By default, scope is false which means to use the nearest outer controller's scope. Sometimes, we don't want to contaminate the outer scope. So we can set scope to true to create a new scope which inherits all objects of the nearest outer scope. Then we can add to this new scope new objects which are invisible to outer controllers.

Moreover, we may want to create a purely new scope without inheriting outer scope's objects. In this case, we can create an isolated scope using an object literal like the following code piece:

JavaScript
angular.module('myMod', []).directive('myDirective', function () {
    return {
        ...
        template: '<h5>{{name}}</h5><h5>{{age}}</h5>',
        scope: {
            name: '@',
            age: '=newAge',
            incomeChanged: '&'
        },
        link: function (scope) {
            if (scope.incomeChanged) {
                scope.incomeChanged({ newIncome: 1234 });
            }
        }
    };
});

The new scope has 3 members that use 3 different ways to bind outer controllers.

  • name: '@' means this member gets the value of the 'name' attribute of the matched element. The value is passed in as a pure string and is just a one-way binding.
  • age: '=newAge' means this member has a two-way binding with an outer controller's object specified by the 'new-age' attribute of the matched element.
  • incomeChanged: '&' means this member is a function specified by the 'income-changed' attribute of the matched element. This is a good way to inject a callback function from the outer controller to the custom directive.

As the example demonstrates, we can omit the attribute name if we want it to be the same as the member name.

Now we can use the directive in HTML as shown below:

HTML
<div ng-controller="outerController">
    <my-directive name="{{userName}}" 
                  new-age="userAge" 
                  income-changed="onIncomeChanged(newIncome)">
    </my-directive>
</div>

Both userName and userAge are outerController's member variables and onIncomeChanged is its member function. It's very important to notice the function argument - newIncome. We should pass the value in an object literal as myDirective's link function indicates.

Compile and Link

For every custom directive, compile and link have the following differences:

  • compile is antecedent to link in the calling sequence.
  • link can access scope while compile cannot because scope is not created yet during compliling time.
  • compile only executes once while link executes multiple times when the matched element can be duplicated by ngRepeat.
  • We can return pre and post link functions in compile. Actually, post equals to link. Code could be written like below:
JavaScript
compile: function (element, attrs, transclude) {
    return {
        pre: function (scope, element, attrs) { ... },
        post: function (scope, element, attrs) { ... }
    };
}

Regardless of the difference, we can use either compile or link for a general purpose.

Explain fuFileBrowser

Usage

This directive enables ngModel binding for the HTML5 file control - <input type="file"/>. To use this directive, we need to:

  1. Reference the script file via <script src="directives/filebrowser.js"></script>.
  2. Inject the dependency in module declaration like angular.module('demoApp', ['fu.directives.fileBrowser']).
  3. Use the directive in HTML like <input fu-file-browser type="file" ng-model="fileList" />.

Now we can get the selected file through the outer controller's $scope.fileList variable.

To enable selecting multiple files, we can add the fu-multiple attribute; to clean the selections immediately after files are selected, we can add the fu-resetable attribute.

Code Explanation

We can have a look in filebrowser.js.

JavaScript
angular.module('fu.directives.fileBrowser', []).directive('fuFileBrowser', function () {
    return {
        restrict: 'EA',
        require: 'ngModel',
        replace: true,
        template: '<div><div><input type="file" 
        style="cursor:pointer"/></div></div>',
        link: function (scope, element, attrs, ngModel) {
            var container = element.children();
            var bindFileControlChange = function () {
                var fileControl = container.children();
                fileControl.prop('multiple', attrs.fuMultiple !== undefined);
                fileControl.change(function (evt) {
                    scope.$apply(function () {
                        ngModel.$setViewValue(evt.target.files);
                    });
                    if (attrs.fuResetable === undefined) {
                        return;
                    }
                    container.html(container.html()); // Reset must be done on div level
                    bindFileControlChange(); // Rebind after reset
                });
            };
            bindFileControlChange();
        }
    };
});

First, I declared the dependency on the ngModel's controller by require:'ngModel'. Then, I injected ngModel to the link function and used its API - $setViewValue to change the binded value in the outer controller's scope. We must make the change in scope.$apply to let Angular know it.

You can notice I used two levels of <div> for this directive. It is for resetting the file control. I used the div.html() function to reset the file control every time when I have passed the selected files to ngModel.

Explain fuFileDropper

Usage

This directive enables ngModel binding for files dropped to a <div> area. The steps to use it are very similar to fuFileBrowser:

  1. Reference the script file via <script src="directives/filedropper.js"></script>.
  2. Inject the dependency in module declaration like angular.module('demoApp', ['fu.directives.fileDropper']).
  3. Use the directive in HTML like <div fu-file-dropper ng-model="filesDropped">Drop files here</div>.

Now you can get the dropped files through the outer controller's $scope.filesDropped variable.

Code Explanation

We can have a look in filedropper.js.

JavaScript
angular.module('fu.directives.fileDropper', []).directive('fuFileDropper', function () {
    return {
        restrict: 'EA',
        require: 'ngModel',
        replace: true,
        transclude: true,
        template: '<div class="fu-drop-area" ng-transclude></div>',
        link: function (scope, element, attrs, ngModel) {
            var dropZone = element;
            var dropZoneDom = element.get(0);
            dropZoneDom.addEventListener('dragover', function (evt) {
                evt.stopPropagation();
                evt.preventDefault();
                evt.dataTransfer.dropEffect = 'copy';
                dropZone.addClass("dragover");
            }, false);
            dropZoneDom.addEventListener('dragleave', function (evt) {
                evt.stopPropagation();
                evt.preventDefault();
                dropZone.removeClass("dragover");
            }, false);
            dropZoneDom.addEventListener('drop', function (evt) {
                evt.stopPropagation();
                evt.preventDefault();
                dropZone.removeClass("dragover");
                scope.$apply(function () {
                    ngModel.$setViewValue(evt.dataTransfer.files);
                });
            }, false);
        }
    };
});

The injection of ngModel is the same as fuFileBrowser. I transcluded the content of the matched element into <div> of the template. I had to use event listeners to monitor drag-drop events. You can find the style classes in main.css of the demo.

References

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)