4

I have seen examples that use directives to enable AngularJS to access the content or properties of a file (for example in Alex Such's fiddle and blog post) but I would have thought the following simple code would work (it doesn't).

HTML:

<body ng-app="myapp">
    <div id="ContainingDiv" ng-controller="MainController as ctrl">
        <input id="uploadInput" type="file" name="myFiles" onchange="grabFileContent()" />
        <br />
        {{ ctrl.content }}
    </div>
</body>

JavaScript:

var myapp = angular.module('myapp', []);

myapp.controller('MainController', function () {
    this.content = "[Waiting for File]";
    this.showFileContent = function(fileContent){
        this.content = fileContent;
    };
});

var grabFileContent = function() {
    var files = document.getElementById("uploadInput").files;
    if (files && files.length > 0) {
        var fileReader = new FileReader();
        fileReader.addEventListener("load", function(event) {
            var controller = angular.element(document.getElementById('ContainingDiv')).scope().ctrl;
            controller.showFileContent(event.target.result);
        });
        fileReader.readAsText(files[0]);
    }
};

If I place a breakpoint on the line this.content = fileContent I can see that the value of content changes from "[Waiting for File]" and is replaced by the content of the chosen txt file (in my case "Hallo World"). A breakpoint on controller.showFileContent(event.target.result) shows the same, the value changes from "[Waiting for File]" to "Hallo World".

But the HTML never re-renders, it stays as "[Waiting for File]". Why?

(N.B. I've put the code in a fiddle.)

dumbledad
  • 16,305
  • 23
  • 120
  • 273
  • I tried [swapping from 'this' to '$scope'](http://stackoverflow.com/q/11605917/575530) but the HTML still fails to render the update ([revised fiddle](http://jsfiddle.net/dumbledad/c54yk17t/2/)). – dumbledad Dec 15 '14 at 17:30

4 Answers4

4

The main concept of events outside Angular is correct, but you have 2 places you are going outside of the Angular context:

  1. onchange="grabFileContent()" causes all of grabFileContent() to be run outside of the Angular context
  2. fileReader.addEventListener('load', function(event){ ... causes the callback to be run outside of the Angular context

Here is how I would do it. First, move the onchange event into the angular context and the controller:

<input id="uploadInput" type="file" name="myFiles" ng-model="ctrl.filename" ng-change="grabFileContent()" />

And now from within your controller:

myapp.controller('MainController', function ($scope) {
    this.content = "[Waiting for File]";
    this.showFileContent = function(fileContent){
        this.content = fileContent;
    };
    this.grabFileContent = function() {
        var that = this, files = this.myFiles; // all on the scope now
        if (files && files.length > 0) {
            var fileReader = new FileReader();
            fileReader.addEventListener("load", function(event) {
                // this will still run outside of the Angular context, so we need to 
                // use $scope.$apply(), but still...
                // much simpler now that we have the context for the controller
                $scope.$apply(function(){
                    that.showFileContent(event.target.result);
                });
            });
            fileReader.readAsText(files[0]);
        }
    };
});
deitch
  • 14,019
  • 14
  • 68
  • 96
  • Thanks @deitch. This is fascinating but does it work? I've added your code to [a fiddle](http://jsfiddle.net/dumbledad/xkjznmg2/) and the HTML does not update with the file's content. – dumbledad Dec 16 '14 at 09:12
  • Looking in a debugger `this.grabFileContent` is never called, so it looks like `ng-change` is not successfully routing the event to the handler. It looks like this was an open AngularJS issue, [but was closed](https://github.com/angular/angular.js/issues/1375#issuecomment-43820650). Oddly they seem to assume that handling file input is synonymous with file upload which is I think why they have not implemented it. – dumbledad Dec 16 '14 at 09:35
  • So, if I'm correct that this is a known omission and AngularJS does not route events from the file input HTML control, then I do now have **[working code here](http://jsfiddle.net/dumbledad/xkjznmg2/2/)** which keeps the event handler outside the Angular context as it was in my question but uses your technique to use `$scope.$apply()` within the conttroller's `showFileContent` method. – dumbledad Dec 16 '14 at 09:54
  • Huh! I missed that `ng-change` issued. Nice catch @dumbledad – deitch Dec 16 '14 at 11:19
1

When listening to events outside AngularJS environment (such as DOM events or your FileReader event), you need to wrap the listener code in an $apply() call to propertly trigger a $digest and subsequently update the view.

fileReader.addEventListener('load', function (event) {
    $scope.$apply(function () {
        // ... (put your code here)
    });
});

You'll need to pass a scope to your function somehow.


Additionally, as deitch's answer points out, you shouldn't use native event handler attributes like onchange and instead use Angular approach, like ng-change. In case of file input, this won't work, and you'd likely be best off by creating a directive that catches the native change event and updates your scope variable with file content:

.directive('fileContent', ['$parse', function ($parse) {
    return {
        compile: function (element, attrs) {
            var getModel = $parse(attrs.fileContent);

            return function link ($scope, $element, $attrs) {
                $element.on('change', function () {
                    var reader = new FileReader();
                    reader.addEventListener('load', function (event) {
                        $scope.$apply(function () {
                            // ... (get file content)
                            getModel($scope).assign(/* put file content here */);
                        });
                    });
                    // ... (read file content)
                });
            };
        }
    };
}])

&

<input type="file" file-content="someScopeVariable">

This should automatically keep your scope variable updated with the contents of the currently selected file. It shows a separation of concerns typical for "thinking in Angular".

Community
  • 1
  • 1
hon2a
  • 7,006
  • 5
  • 41
  • 55
  • I now realise that AngularJS [does not support file input binding](https://github.com/angular/angular.js/issues/1375#issuecomment-43820650) and so `ng-change` will not work. I'd love to be wrong though; do you have a working file input code example using `ng-change` instead of `onchange`? – dumbledad Dec 16 '14 at 10:04
  • Sorry, I didn't realize this. In that case I'd suggest wrapping the whole thing in a directive, catching the `change` event in it, and using `$apply()` to wrap the handler code. I'll try updating my answer to show what I mean. – hon2a Dec 16 '14 at 11:04
1

The code in deitch's answer looks right, and has certainly helped me understand things. But AngularJS does not support file input binding and so ng-change will not work. Looking at the discussion in that GitHub issue this appears to be because binding to HTML's file input and uploading the file are seen as synonymous and because implementing the full file API is more complicated. However there are purely local scenarios where we want to load input from a local file into an Angular model.

We could acheive this with an Angular directive as Alex Such does in the fiddle and blog post I mention in the question, or as hon2a's editted answer and laurent's answer do.

But to achieve this without directives we need to handle file input's onchange outside of the Angular context but then use Angular's $scope.$apply() to alert Angular to the resulting changes. Here is some code that does this:

HTML:

<div id="ContainingDiv" ng-controller="MainController as ctrl">
    <input id="uploadInput" type="file" name="myFiles" onchange="grabFileContent()" />
    <br />
    {{ ctrl.content }}
</div>

JavaScript:

var myApp = angular.module('myApp',[]);

myApp.controller('MainController', ['$scope', function ($scope) {
    this.content = "[Waiting for File]";
    this.showFileContent = function(fileContent){
        $scope.$apply((function(controller){
            return function(){
                controller.content = fileContent;
            };
        })(this));
    };
}]);

var grabFileContent = function() {
    var files = document.getElementById("uploadInput").files;
    if (files && files.length > 0) {
        var fileReader = new FileReader();
        fileReader.addEventListener("load", function(event) {
           var controller = angular.element(document.getElementById('ContainingDiv')).scope().ctrl;
           controller.showFileContent(event.target.result);
        });
        fileReader.readAsText(files[0]);
    }
};
Community
  • 1
  • 1
dumbledad
  • 16,305
  • 23
  • 120
  • 273
  • For me Alex Such's code at fiddle worked. Your answer gave me a Reference Error: grabFileContent is undefined, but thank you anyway. – Andre Rocha Sep 14 '17 at 19:17
1

I wrote a wrapper around the FileReader API, which you can find here

It basically wraps FileReader prototype methods in Promise and make sure the event handlers are called within an $apply function so the bridge with Angular is done.

There is a quick example on how to use it from a directive to display preview of image

(function (ng) {
'use strict';
ng.module('app', ['lrFileReader'])
    .controller('mainCtrl', ['$scope', 'lrFileReader', function mainCtrl($scope, lrFileReader) {
        $scope.$watch('file', function (newValue, oldValue) {
            if (newValue !== oldValue) {
                lrFileReader(newValue[0])
                    .on('progress', function (event) {
                        $scope.progress = (event.loaded / event.total) * 100;
                        console.log($scope.progress);
                    })
                    .on('error', function (event) {
                        console.error(event);
                    })
                    .readAsDataURL()
                    .then(function (result) {
                        $scope.image = result;
                    });
            }
        });
    }])
    .directive('inputFile', function () {
        return{
            restrict: 'A',
            require: 'ngModel',
            link: function linkFunction(scope, element, attrs, ctrl) {


                //view->model
                element.bind('change', function (evt) {
                    evt = evt.originalEvent || evt;
                    scope.$apply(function () {
                        ctrl.$setViewValue(evt.target.files);
                    });
                });

                //model->view
                ctrl.$render = function () {
                    //does not support two way binding
                };
            }
        };
    });
})(angular);

Note the inputFile directive which allow the binding with a File to a model property. Of course the binding is only one way as the input element does not allow to set the file (for security reason)

laurent
  • 2,590
  • 18
  • 26
  • Thanks @laurent. I was trying to avoid using directives (I mention [Alex Such's solution](http://veamospues.wordpress.com/2014/01/27/reading-files-with-angularjs/) in the question), as it felt like it should be straightforward without them, but it's good to see how it is done with them. – dumbledad Dec 16 '14 at 10:59