2
\$\begingroup\$

I took the basic layout manipulation from a jQuery plugin called flexImages. The idea is to use the directive tag and encapsulate a piece of code that will be transcluded to make up the container cells of each row. Unlike the jQuery plugin I didn't want to force defining the initial width and height of the container cells but rather dynamically calculate the height and width which turns out to be a huge challenge. It doesn't do more than a single image, however, it is possible to bind behaviors to the image, and like in Google image search superimpose HTML DOM content on the image. I figure I could continue to write the script to parse each container cell and add the height and width of each element so that it knows the dimensions and aspect ratio of each container cell. I'm also, as my first AngularJS directive, am a little proud I unbind event handlers when the scope is destroyed.

Since ng-transclude and ng-repeat don't work together, I had use a work around by creating a directive that functions as an attribute which gets added to the ng-repeat element. The ng-repeat element has the transclude property on the directive set to true. Because transclude is inherited from the parent along with the scope, the work around directive calls the transclude function with an empty scope, and the parent scope's properties are all inherited.

I wasn't sure what goes in the linking function and what goes in the directive controller so I tried to keep methods for working with the scope data in the controller and jQuery stuff inside the linking function. I don't think that it really matters because they both have access to the scope and the linking functions have access to the controller. I logged when each one fired and the controller was fired first, but since everything is async, the controller couldn't work with the stuff the linking functions were doing until that information became available. So rather than initialize items on the scope first in the controller, I let the linking function access a method on the controller and then force the initialization to make sure that happened before the linking function started to work on it.

Here is an image:

enter image description here

Yes, I know, it would be better to render the images on the server, but this seemed like a fun challenge.

angular.module('battle')
    .controller('fgImageGridController', fgImageGridController)
    .directive('fgImageGrid', fgImageGrid)
    .directive('fgImageGridInjector', fgImageGridInjector);

fgImageGridController.$inject = ['$scope', '$timeout'];
fgImageGrid.$inject = ['$window', '$timeout'];
fgImageGridInjector.$inject = [];

function fgImageGridController($scope, $timeout) {
    var _this = this,
        debounceTimeout;

    this.items = [];

    this.extendItem = function(item) {
        angular.extend(item, {
            $$hasImages: false,
        $$imagesLoaded: false,
            $$processed: false,
        $$element: null
        });
    };

    this.getRenderQueue = function() {
        return this.items.filter(function(item) {
            if(item.$$imagesLoaded == true) {
                return item;
            }
        });
    };

    this.getImagesLoadedState = function() {
        return this.items.map(function(item) {
            return item.$$imagesLoaded;
        });
    };

    this.digestDebounce = function(wait) {
        wait = wait || 500;
        if (debounceTimeout) $timeout.cancel(debounceTimeout);
        debounceTimeout = $timeout(function() {
            $scope.$digest();
        }, wait)
    };

};

function fgImageGrid($window, $timeout) {
    return {
        restrict: 'E',
        transclude: true,
        scope: {
            height: '@',
            items: '='
        },
        bindToController: true,
        controllerAs: 'ctrl',
        controller: 'fgImageGridController',
        templateUrl: 'templates/fg-image-grid.tpl.html',
        link: function(scope, element, attrs) {
            scope.$watchCollection(function(){
                return scope.ctrl.getImagesLoadedState();
            }, function(newValue, oldValue) {
                processItems();
            });

            angular.element($window).on('resize', function() {
                processItems();
            });

            element.on('$destroy', function() {
                console.log('Scope is $destroyed');
                angular.element($window).off('resize');
            });

            var processGridTimeout;
            function processItems() {
                if (processGridTimeout) $timeout.cancel(processGridTimeout);
                processGridTimeout = $timeout(function() {
                    processGridMain();
                    scope.$digest();
                }, 40);
            };

            function processGridMain() {
                var itemsToProcess = scope.ctrl.getRenderQueue();
                if (!itemsToProcess.length)return;

                angular.forEach(itemsToProcess, function(item) {
                    item.$$processed = false;
                });

                var maxRowWidth = $(".fg-grid-container").actual('innerWidth') - 9,
                    maxRowHeight = scope.ctrl.height,
                    items = [];

                angular.forEach(itemsToProcess, function(item, itemIndex) {
                    var element = item.$$element,
                        elementWidth = parseInt(element.find('img').actual('outerWidth', {includeMargin: true})),
                        elementHeight = parseInt(element.find('img').actual('outerHeight', {includeMargin: true})),
                        normalizedElementWidth = elementWidth * (maxRowHeight / elementHeight);

                    items.push({
                        element: element,
                        elementWidth:elementWidth,
                        elementHeight: elementHeight,
                        normalizedElementWidth: normalizedElementWidth
                    });
                });

                var rowItems = [],
                    rowWidth = 0,
                    ratio,
                    finalElementWidth,
                    rowHeight,
                    exactRowWidth;

                angular.forEach(items, function(item){
                    rowItems.push(item);
                    rowWidth = rowWidth + item.normalizedElementWidth;

                    if (rowWidth >= maxRowWidth) {
                            ratio = maxRowWidth / rowWidth,
                            rowHeight = Math.ceil(maxRowHeight * ratio),
                            exactRowWidth = 0;

                        angular.forEach(rowItems, function(rowItem) {
                            finalElementWidth = Math.floor(rowItem.normalizedElementWidth * ratio);
                            exactRowWidth = exactRowWidth + finalElementWidth;
                            if (exactRowWidth > maxRowWidth) {
                                finalElementWidth = finalElementWidth - (exactRowWidth - maxRowWidth);
                            }
                            rowItem.element.width(finalElementWidth);
                            rowItem.element.height(rowHeight);
                            rowItem.element.css('display', 'block');
                        });
                        rowWidth = 0; rowItems = [];
                    }
                });

                // The rowItems array might still have unrendered items. They get rendered
                // using the last line's height.
                angular.forEach(rowItems, function(rowItem) {
                    finalElementWidth = Math.floor(rowItem.normalizedElementWidth + ratio);
                    rowHeight = Math.ceil(maxRowHeight * ratio);

                    rowItem.element.width(finalElementWidth);
                    rowItem.element.height(rowHeight);
                    rowItem.element.css('display', 'block');
                });

                angular.forEach(itemsToProcess, function(item) {
                    item.$$processed = true;
                });

            }
        }
    };
};
function fgImageGridInjector() {
    return {
        require: '^fgImageGrid',
        link: function(scope, element, attrs, controller, transclude) {
            if (!transclude) {
                throw angular.$$minErr('ngTransclude')('orphan',
                'Illegal use of ngTransclude directive in the template! ' +
                'No parent directive that requires a transclusion found. ' +
                'Element:');
            }
            var innerScope = scope.$new();

            transclude(innerScope, function(clone) {
                element.empty();
                element.append(clone);

                var images = element.find('img'),
                    item = element.scope().item,
                    numImgs = images.length;

                controller.extendItem(item);

                if(images.length > 0) {
                    item.$$hasImages = true;
                item.$$element = element;
                    images.each(function(index, image) {
                        angular.element(image).on('load', function(event) {
                            numImgs--;
                            if (numImgs == 0) {
                                item.$$imagesLoaded = true;
                                element.addClass('images-loaded');
                                controller.digestDebounce();
                            }
                        });
                        element.on('$destroy', function() {
                            angular.element(image).off();
                            innerScope.$destroy();
                        });
                    });
                }
            });
        }
    };
};

The template:

<div class="fg-grid-container" ng-if="ctrl.items">
    <div class=grid-item"
         style="float: left; position: relative;"
         ng-repeat="item in ctrl.items track by $index"
         fg-image-grid-injector
         ng-show="item.$$processed">
    </div>
</div>

Calling the directive:

    <fg-image-grid height="120" items="homeCtrl.images">
        <img class="img-thumbnail" ng-src="{{item.image}}"/>
    </fg-image-grid>
\$\endgroup\$
1
\$\begingroup\$

An interesting piece of code!

Here are some remarks on code readability and organization.

  • Having dollars feels like a system variable such as $$hasImages. Is it? If no, it can be confused with angular's own set variables. And if yes, they are better to avoid.

  • var _this = this is never used?

  • extendItem is that kind of naming that gives me no idea about the real purpose. Maybe some more expressive name that gives me some idea what it does without the need to find and read it?

  • scope.ctrl.getImagesLoadedState(); If I understand correctly, this refers from the directive to the outside controller. It feels like breaking the encapsulation provided by the directive. The directive should be re-usable and should not know anything about the outside controllers. Any communication can be done via Services injected by the directive.

  • processItems(); Again, more descriptive naming would make it easier to see the intention behind.

  • var processGridTimeout; It is a good practice to declare your variables at the top of the function.

  • $timeout(..., 40) It is better to avoid "magic" numbers like 40.

  • var maxRowWidth = $(".fg-grid-container") That feels like jQuery and is anti-pattern inside Angular directives. Only descendents of elelement should be used, or else the directive is misplaced.

  • throw angular.$$minErr Are you planning to catch and handle the error? In general, Angular has forgiving behavior and would never throw exception. It is more gentle to change the route and display a message to the user.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.