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:
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>