"
10 | ],
11 | "license": "MIT",
12 | "homepage": "https://github.com/tinydesk/angular-tileview",
13 | "ignore": [
14 | "**/.*",
15 | "node_modules",
16 | "bower_components",
17 | "test",
18 | "tests"
19 | ],
20 | "dependencies": {
21 | "angular-scroll-rtl": "^0.1.1"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/demo/demo.css:
--------------------------------------------------------------------------------
1 | .demo {
2 | height: 700px;
3 | border: 1px solid;
4 | }
5 |
6 | .item-cell-padding {
7 | padding: 10px;
8 | }
9 |
10 | .item-cell-padding.small {
11 | padding: 2px;
12 | }
13 |
14 | .item-cell {
15 | border: 1px solid;
16 | height: 100%;
17 | padding: 10px;
18 | }
19 |
20 | .item-cell img {
21 | margin-bottom: 5px;
22 | }
23 |
24 | .controls {
25 | padding: 20px;
26 | }
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | TileView: Demo
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
149 |
159 |
164 |
165 |
166 |
167 |
--------------------------------------------------------------------------------
/demo/indexSlow.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | TileView: Demo
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
64 |
65 |
66 |
67 |
68 |
69 |
153 |
160 |
165 |
166 |
167 |
168 |
--------------------------------------------------------------------------------
/dist/tileview.css:
--------------------------------------------------------------------------------
1 | .tile-view {
2 | height: 100%;
3 | overflow-y: auto;
4 | overflow-x: hidden;
5 | }
6 | .tile-view .item-container {
7 | position: relative;
8 | }
9 | .tile-view.horizontal {
10 | white-space: nowrap;
11 | overflow-x: auto;
12 | }
13 | .tile-view.horizontal > * {
14 | vertical-align: top;
15 | display: inline-block;
16 | height: 100%;
17 | }
18 | .tile-view.horizontal .item-container > div > * {
19 | white-space: normal;
20 | }
21 |
--------------------------------------------------------------------------------
/dist/tileview.js:
--------------------------------------------------------------------------------
1 | (function () {
2 | 'use strict';
3 | var mod = angular.module('td.tileview', ['td.scroll']);
4 | /**
5 | * @ngdoc directive
6 | * @name td.tileview.directive:tdTileview
7 | * @restrict E
8 | *
9 | * @description
10 | *
11 | * The tile directive provides a tile-based view on a list of data. The tiles can be arranged in a grid or they can be
12 | * horizontally stacked.
13 | *
14 | * The tile directive will automatically resize when the window is resized. If the size changed for some other reasons, a manual resize
15 | * can be triggered, by broadcasting the `td.tileview.resize` event. There are two other events, that indicate the beginning and ending
16 | * of a scrolling movement. These events can be used to implement custom performance optimisations, because not every DOM change needs to
17 | * be done while scrolling. The events are: `td.tileview.scrollStart` and `td.tileview.scrollEnd`. In order to detect when scrolling ends
18 | * a debounce delay is used. It can be configured with the `afterScrollDelay` options property.
19 | *
20 | * @param {Array=} items The items that are to be displayed in the tile view
21 | * @param {object=} options An options object defining options that are relevant specifically for the tile ui such as
22 | * tile sizes for example. It consists of the following properties:
23 | *
24 | * - **templateUrl** - {string} - Path to the template that should be used to render tiles. The template will implicitly have
25 | * access to the tile directive's scope plus an `item` object. Note that the template is responsible for maintaining the
26 | * selection state by calling the appropriate methods on the selection object.
27 | * - **tileSize** - {object} - The current tile size represented as an object with the following properties:
28 | * - **width** - {int} - The width of the tile.
29 | * - **height** - {int} - The height of the tile.
30 | * Can be dynamically adjusted.
31 | * - **alignHorizontal** - {boolean} - Whether to show the tiles in a grid with a vertical scrollbar or horizontally
32 | * stacked.
33 | * - **onScrollEnd** - {function} - A callback that is invoked when the user scrolls to the end of the data.
34 | * - **scrollEndOffset** - {number} - Some features that rely on the `scrollEnd` callback need to be informed in advance.
35 | * This property specifies an offset in rows to trigger the scroll end event before actually hitting the bottom of the data. **Default**: 0
36 | * - **overflow** - {number} - Number of rows that are rendered additionally to the visible rows to make the scrolling experience more fluent. **Default**: 2
37 | * - **debounce** - {number} - Debounce for the scroll event. A value of `0` is interpreted as no debounce. **Default**: 0.
38 | * - **afterScrollDelay** - {number} - Time to wait in order to decide whether a scroll movement has finished. **Default**: 100.
39 | */
40 | mod.directive('tdTileview', ['$compile', '$templateCache', '$timeout', '$window', function ($compile, $templateCache, $timeout, $window) {
41 | return {
42 | restrict: 'E',
43 | scope: {
44 | items: '=',
45 | options: '='
46 | },
47 | template: $templateCache.get('tileview.tpl.html'),
48 | link: function (scope, elem, attrs) {
49 | scope.elem = elem;
50 | scope.tileStyle = {};
51 | scope.tileStyle.marginRight = "4px";
52 | scope.tileStyle.marginBottom = "4px";
53 | scope.tileStyle.float = "left";
54 | var container = elem.children();
55 | var itemContainer = container.children().eq(0);
56 | var linkFunction;
57 | var heightStart = 0;
58 | var heightEnd = 0;
59 | var startRow = 0, endRow;
60 | var renderedStartRow = -1, renderedEndRow = -1;
61 | var itemsPerRow;
62 | var rowCount;
63 | var cachedRowCount;
64 | var virtualRows = [];
65 | var scopes = {};
66 | var scopeCounter = 0;
67 | function nextScopeId() {
68 | scopeCounter = scopeCounter + 1;
69 | return 'scope-' + scopeCounter;
70 | }
71 | function handleTileSizeChange() {
72 | forEachElement(function (el) {
73 | el.css('width', scope.options.tileSize.width + 'px');
74 | el.css('height', scope.options.tileSize.height + 'px');
75 | });
76 | }
77 | function handleTemplateUrlChange() {
78 | var template = $templateCache.get(scope.options.templateUrl);
79 | if (template !== undefined) {
80 | linkFunction = $compile(template);
81 | removeAll();
82 | }
83 | else {
84 | console.error('Template url not found: ' + scope.options.templateUrl);
85 | }
86 | }
87 | function handleAlignHorizontalChange() {
88 | if (scope.options.alignHorizontal) {
89 | sizeDimension = 'width';
90 | minSizeDimension = 'min-width';
91 | orthogonalDimension = 'min-height';
92 | elem.children().addClass('horizontal');
93 | }
94 | else {
95 | sizeDimension = 'height';
96 | minSizeDimension = 'min-height';
97 | orthogonalDimension = 'min-width';
98 | elem.children().removeClass('horizontal');
99 | }
100 | }
101 | scope.$watch('options', function (options, currentOptions) {
102 | // set defaults:
103 | options.scrollEndOffset = def(options.scrollEndOffset, 0);
104 | options.overflow = def(options.overflow, 2);
105 | options.debounce = def(options.debounce, 0);
106 | options.afterScrollDelay = def(options.afterScrollDelay, 100);
107 | if (options === currentOptions || options.templateUrl !== currentOptions.templateUrl) {
108 | handleTemplateUrlChange();
109 | }
110 | if (options === currentOptions || options.alignHorizontal !== currentOptions.alignHorizontal) {
111 | handleAlignHorizontalChange();
112 | }
113 | layout(true);
114 | if (options === currentOptions || options.tileSize.width !== currentOptions.tileSize.width || options.tileSize.height !== currentOptions.tileSize.height) {
115 | handleTileSizeChange();
116 | }
117 | }, true);
118 | var sizeDimension, minSizeDimension, orthogonalDimension;
119 | scope.$watchCollection('items', function () {
120 | lastScrollPosition = Number.NEGATIVE_INFINITY;
121 | layout(true);
122 | });
123 | var resizeTimeout;
124 | scope.$on('td.tileview.resize', function () {
125 | // this might be called within a $digest
126 | if (resizeTimeout) {
127 | $timeout.cancel(resizeTimeout);
128 | }
129 | resizeTimeout = $timeout(resize, 50, false);
130 | });
131 | scope.$on('td.tileview.update', function () {
132 | layout(true);
133 | });
134 | angular.element($window).on('resize', onResize);
135 | scope.$on('$destroy', function () {
136 | angular.element($window).off('resize', onResize);
137 | // unregister all timers:
138 | if (resizeTimeout !== undefined) {
139 | $timeout.cancel(resizeTimeout);
140 | }
141 | if (scrollEndTimeout !== undefined) {
142 | $timeout.cancel(scrollEndTimeout);
143 | }
144 | if (debounceTimeout !== undefined) {
145 | $timeout.cancel(debounceTimeout);
146 | }
147 | removeAll();
148 | });
149 | function removeElement(el) {
150 | var id = el.attr('id');
151 | if (scopes[id] !== undefined) {
152 | scopes[id].$destroy();
153 | delete scopes[id];
154 | }
155 | el.remove();
156 | }
157 | function removeAll() {
158 | forEachRow(removeRow);
159 | }
160 | function forEachElement(fn) {
161 | forEachRow(function (row, rowIndex) {
162 | for (var i = 0; i < row.children().length; ++i) {
163 | fn(row.children().eq(i), rowIndex * itemsPerRow + i);
164 | }
165 | });
166 | }
167 | function forEachRow(fn) {
168 | var numOfRows = visibleRowCount();
169 | for (var i = 0; i < numOfRows; ++i) {
170 | fn(itemContainer.children().eq(i), startRow + i);
171 | }
172 | }
173 | function visibleRowCount() {
174 | return itemContainer.children().length;
175 | }
176 | function itemElementCount() {
177 | return visibleRowCount() * itemsPerRow;
178 | }
179 | var lastScrollPosition = Number.NEGATIVE_INFINITY;
180 | function updateVisibleRows() {
181 | function clamp(value, min, max) {
182 | return Math.max(Math.min(value, max), min);
183 | }
184 | var rect = container[0].getBoundingClientRect();
185 | var itemSize = scope.options.tileSize[sizeDimension];
186 | var maxScrollPosition = rowCount * itemSize - rect[sizeDimension];
187 | var scrollPosition = scope.options.alignHorizontal ?
188 | container.scrollLeft() :
189 | container[0].scrollTop;
190 | var scrollEndThreshold = maxScrollPosition - scope.options.scrollEndOffset * itemSize;
191 | if (scrollPosition >= scrollEndThreshold && !(lastScrollPosition >= scrollEndThreshold) && scope.options.onScrollEnd !== undefined) {
192 | scope.options.onScrollEnd();
193 | }
194 | startRow = clamp(Math.floor(scrollPosition / itemSize) - scope.options.overflow, 0, rowCount - cachedRowCount);
195 | endRow = startRow + cachedRowCount;
196 | lastScrollPosition = scrollPosition;
197 | }
198 | function updateItem(elem, index, digest) {
199 | var item = scope.items[index];
200 | if (item !== undefined) {
201 | if (elem.css('display') === 'none') {
202 | elem.css('display', 'inline-block');
203 | }
204 | var itemScope = scopes[elem.attr('id')];
205 | itemScope.item = item;
206 | itemScope.$index = index;
207 | if (digest === true) {
208 | itemScope.$digest();
209 | }
210 | }
211 | else {
212 | elem.css('display', 'none');
213 | }
214 | }
215 | function updateRow(el, rowIndex, digest) {
216 | var ch = el.children();
217 | for (var i = 0; i < ch.length; ++i) {
218 | updateItem(ch.eq(i), rowIndex * itemsPerRow + i, digest);
219 | }
220 | var translate = Math.max(rowIndex * scope.options.tileSize[sizeDimension], 0);
221 | //el.css('transform', `${translate}(${Math.max(rowIndex * scope.options.tileSize[sizeDimension], 0)}px), translateZ(${rowIndex})`);
222 | if (scope.options.alignHorizontal) {
223 | if (itemContainer.direction() === 'rtl') {
224 | translate = -translate;
225 | }
226 | el.css('transform', "translate3d(" + translate + "px, 0px, 0)");
227 | }
228 | else {
229 | el.css('transform', "translate3d(0px, " + translate + "px, 0)");
230 | }
231 | }
232 | function addRow() {
233 | var row = angular.element('
');
234 | row.css('position', 'absolute');
235 | itemContainer.append(row);
236 | return row;
237 | }
238 | function clearRow(row) {
239 | while (row.children().length > 0) {
240 | removeElementFromRow(row);
241 | }
242 | }
243 | function removeRow() {
244 | var row = itemContainer.children().eq(-1);
245 | clearRow(row);
246 | row.remove();
247 | }
248 | function addElementToRow(row) {
249 | var newScope = scope.$parent.$new();
250 | linkFunction(newScope, function (clonedElement) {
251 | clonedElement.css({
252 | width: scope.options.tileSize.width + 'px',
253 | height: scope.options.tileSize.height + 'px',
254 | display: 'inline-block',
255 | 'vertical-align': 'top'
256 | });
257 | var scopeId = nextScopeId();
258 | clonedElement.attr('id', scopeId);
259 | scopes[scopeId] = newScope;
260 | row.append(clonedElement);
261 | });
262 | }
263 | function fillRow(row) {
264 | var currentRowLength = row.children().length;
265 | if (currentRowLength < itemsPerRow) {
266 | for (var i = currentRowLength; i < itemsPerRow; ++i) {
267 | addElementToRow(row);
268 | }
269 | }
270 | else if (currentRowLength > itemsPerRow) {
271 | for (var i = currentRowLength; i > itemsPerRow; --i) {
272 | removeElementFromRow(row);
273 | }
274 | }
275 | }
276 | function removeElementFromRow(row) {
277 | removeElement(row.children().eq(-1));
278 | }
279 | function createElements(numRows) {
280 | updateVisibleRows();
281 | var currentRowCount = itemContainer.children().length;
282 | if (currentRowCount < numRows) {
283 | for (var i = currentRowCount; i < numRows; ++i) {
284 | addRow();
285 | }
286 | }
287 | else if (currentRowCount > numRows) {
288 | for (var i = currentRowCount; i > numRows; --i) {
289 | removeRow();
290 | }
291 | }
292 | forEachRow(fillRow);
293 | virtualRows = [];
294 | var startIndex = startRow * itemsPerRow;
295 | forEachRow(function (el, i) {
296 | virtualRows.push(el);
297 | updateRow(el, i, false);
298 | });
299 | renderedStartRow = startRow;
300 | renderedEndRow = endRow;
301 | }
302 | function resize() {
303 | var newComponentSize = container[0].getBoundingClientRect();
304 | if (newComponentSize.width !== componentWidth || newComponentSize.height !== componentHeight) {
305 | if (layout(false)) {
306 | forEachElement(function (el) { return scopes[el.attr('id')].$digest(); });
307 | }
308 | }
309 | }
310 | function onResize() {
311 | resize();
312 | }
313 | function measure() {
314 | var rect = container[0].getBoundingClientRect();
315 | componentWidth = rect.width;
316 | componentHeight = rect.height;
317 | var itemWidth = scope.options.tileSize.width;
318 | var width = rect.width;
319 | var size = rect[sizeDimension];
320 | var newItemsPerRow = (scope.options.alignHorizontal) ? 1 : Math.floor(width / itemWidth);
321 | var newCachedRowCount = Math.ceil(size / scope.options.tileSize[sizeDimension]) + scope.options.overflow * 2;
322 | var changes = newItemsPerRow !== itemsPerRow || newCachedRowCount !== cachedRowCount;
323 | itemsPerRow = Math.max(newItemsPerRow, 1);
324 | cachedRowCount = newCachedRowCount;
325 | rowCount = Math.ceil(scope.items.length / itemsPerRow);
326 | return changes;
327 | }
328 | var componentWidth = 0, componentHeight = 0;
329 | function layout(alwaysLayout) {
330 | if (linkFunction !== undefined && scope.items !== undefined && sizeDimension !== undefined) {
331 | if (measure() || alwaysLayout) {
332 | createElements(cachedRowCount);
333 | itemContainer.css(minSizeDimension, rowCount * scope.options.tileSize[sizeDimension] + 'px');
334 | itemContainer.css(orthogonalDimension, '100%');
335 | //setPlaceholder();
336 | scope.$parent.$broadcast('td.tileview.layout');
337 | return true;
338 | }
339 | }
340 | return false;
341 | }
342 | function update() {
343 | updateVisibleRows();
344 | animationFrameRequested = false;
345 | if (startRow !== renderedStartRow || endRow !== renderedEndRow) {
346 | if (startRow > renderedEndRow || endRow < renderedStartRow) {
347 | virtualRows.forEach(function (el, i) { return updateRow(el, startRow + i, true); });
348 | }
349 | else {
350 | var intersectionStart = Math.max(startRow, renderedStartRow);
351 | var intersectionEnd = Math.min(endRow, renderedEndRow);
352 | if (endRow > intersectionEnd) {
353 | // scrolling downwards
354 | for (var i = intersectionEnd; i < endRow; ++i) {
355 | var e = virtualRows.shift();
356 | updateRow(e, i, true);
357 | virtualRows.push(e);
358 | }
359 | }
360 | else if (startRow < intersectionStart) {
361 | // scrolling upwards
362 | for (var i = intersectionStart - 1; i >= startRow; --i) {
363 | var e = virtualRows.pop();
364 | updateRow(e, i, true);
365 | virtualRows.unshift(e);
366 | }
367 | }
368 | }
369 | renderedStartRow = startRow;
370 | renderedEndRow = endRow;
371 | }
372 | }
373 | function detectScrollStartEnd() {
374 | if (scope.options.afterScrollDelay !== undefined) {
375 | if (scrollEndTimeout !== undefined) {
376 | $timeout.cancel(scrollEndTimeout);
377 | }
378 | else {
379 | scope.$parent.$broadcast('td.tileview.scrollStart');
380 | }
381 | scrollEndTimeout = $timeout(function () {
382 | // scrolling ends:
383 | scrollEndTimeout = undefined;
384 | scope.$parent.$broadcast('td.tileview.scrollEnd');
385 | }, scope.options.afterScrollDelay, false);
386 | }
387 | }
388 | var debounceTimeout, scrollEndTimeout;
389 | var animationFrameRequested = false;
390 | function onScroll() {
391 | detectScrollStartEnd();
392 | if (scope.options.debounce !== undefined && scope.options.debounce > 0) {
393 | if (debounceTimeout === undefined) {
394 | debounceTimeout = $timeout(function () {
395 | debounceTimeout = undefined;
396 | update();
397 | }, scope.options.debounce, false);
398 | }
399 | }
400 | else {
401 | if (!animationFrameRequested) {
402 | animationFrameRequested = true;
403 | requestAnimationFrame(update);
404 | }
405 | }
406 | }
407 | container.on('scroll', onScroll);
408 | }
409 | };
410 | }]);
411 | // Helper functions:
412 | function def(value, defaultValue) {
413 | return (value !== undefined) ? value : defaultValue;
414 | }
415 | })();
416 |
417 | angular.module("td.tileview").run(["$templateCache", function($templateCache) {$templateCache.put("tileview.tpl.html","");}]);
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | var gulp = require('gulp');
2 | var ts = require('gulp-typescript');
3 | var createTemplateCache = require('gulp-angular-templatecache');
4 | var concat = require('gulp-concat');
5 | var less = require('gulp-less');
6 | var streamqueue = require('streamqueue');
7 |
8 | gulp.task('default', ['build']);
9 |
10 | gulp.task('watch', function() {
11 | return gulp.watch('src/**', ['build'])
12 | });
13 |
14 | gulp.task('build', ['compile', 'less']);
15 |
16 | gulp.task('compile', function () {
17 | streamqueue({ objectMode: true }, compile(), templateCache())
18 | .pipe(concat('tileview.js'))
19 | .pipe(gulp.dest('dist'));
20 | });
21 |
22 | gulp.task('less', function() {
23 | return gulp.src('src/**/*.less')
24 | .pipe(less())
25 | .pipe(gulp.dest('dist'));
26 | });
27 |
28 | function compile() {
29 | return gulp.src('src/**/*.ts')
30 | .pipe(ts({
31 | //noImplicitAny: true
32 | }));
33 | }
34 |
35 | function templateCache() {
36 | return gulp.src('src/**/*.tpl.html')
37 | .pipe(createTemplateCache({
38 | module: 'td.tileview'
39 | }));
40 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "angular-tileview",
3 | "version": "0.6.1",
4 | "description": "A tileview for angular",
5 | "main": "gulpfile.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/tinydesk/angular-tileview.git"
12 | },
13 | "author": "",
14 | "license": "MIT",
15 | "bugs": {
16 | "url": "https://github.com/tinydesk/angular-tileview/issues"
17 | },
18 | "homepage": "https://github.com/tinydesk/angular-tileview#readme",
19 | "devDependencies": {
20 | "gulp": "^3.9.1",
21 | "gulp-angular-templatecache": "^1.8.0",
22 | "gulp-concat": "^2.6.0",
23 | "gulp-less": "^3.0.5",
24 | "gulp-typescript": "^2.13.0",
25 | "streamqueue": "^1.1.1"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/tileview.less:
--------------------------------------------------------------------------------
1 | .tile-view {
2 | height: 100%;
3 | overflow-y: auto;
4 | overflow-x: hidden;
5 |
6 | .item-container {
7 | position: relative;
8 | }
9 |
10 | &.horizontal {
11 | white-space: nowrap;
12 | overflow-x: auto;
13 |
14 | > * {
15 | vertical-align: top;
16 | display: inline-block;
17 | height: 100%;
18 | }
19 |
20 | .item-container {
21 | > div > * {
22 | white-space: normal;
23 | }
24 | }
25 | }
26 | }
--------------------------------------------------------------------------------
/src/tileview.tpl.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/tileview.ts:
--------------------------------------------------------------------------------
1 | // service of module
2 | declare const angular: any;
3 |
4 | (() => {
5 | 'use strict';
6 |
7 | const mod = angular.module('td.tileview', ['td.scroll']);
8 |
9 | /**
10 | * @ngdoc directive
11 | * @name td.tileview.directive:tdTileview
12 | * @restrict E
13 | *
14 | * @description
15 | *
16 | * The tile directive provides a tile-based view on a list of data. The tiles can be arranged in a grid or they can be
17 | * horizontally stacked.
18 | *
19 | * The tile directive will automatically resize when the window is resized. If the size changed for some other reasons, a manual resize
20 | * can be triggered, by broadcasting the `td.tileview.resize` event. There are two other events, that indicate the beginning and ending
21 | * of a scrolling movement. These events can be used to implement custom performance optimisations, because not every DOM change needs to
22 | * be done while scrolling. The events are: `td.tileview.scrollStart` and `td.tileview.scrollEnd`. In order to detect when scrolling ends
23 | * a debounce delay is used. It can be configured with the `afterScrollDelay` options property.
24 | *
25 | * @param {Array=} items The items that are to be displayed in the tile view
26 | * @param {object=} options An options object defining options that are relevant specifically for the tile ui such as
27 | * tile sizes for example. It consists of the following properties:
28 | *
29 | * - **templateUrl** - {string} - Path to the template that should be used to render tiles. The template will implicitly have
30 | * access to the tile directive's scope plus an `item` object. Note that the template is responsible for maintaining the
31 | * selection state by calling the appropriate methods on the selection object.
32 | * - **tileSize** - {object} - The current tile size represented as an object with the following properties:
33 | * - **width** - {int} - The width of the tile.
34 | * - **height** - {int} - The height of the tile.
35 | * Can be dynamically adjusted.
36 | * - **alignHorizontal** - {boolean} - Whether to show the tiles in a grid with a vertical scrollbar or horizontally
37 | * stacked.
38 | * - **onScrollEnd** - {function} - A callback that is invoked when the user scrolls to the end of the data.
39 | * - **scrollEndOffset** - {number} - Some features that rely on the `scrollEnd` callback need to be informed in advance.
40 | * This property specifies an offset in rows to trigger the scroll end event before actually hitting the bottom of the data. **Default**: 0
41 | * - **overflow** - {number} - Number of rows that are rendered additionally to the visible rows to make the scrolling experience more fluent. **Default**: 2
42 | * - **debounce** - {number} - Debounce for the scroll event. A value of `0` is interpreted as no debounce. **Default**: 0.
43 | * - **afterScrollDelay** - {number} - Time to wait in order to decide whether a scroll movement has finished. **Default**: 100.
44 | */
45 | mod.directive('tdTileview', ['$compile', '$templateCache', '$timeout', '$window', ($compile, $templateCache, $timeout, $window) => {
46 | return {
47 | restrict: 'E',
48 | scope: {
49 | items: '=',
50 | options: '='
51 | },
52 | template: $templateCache.get('tileview.tpl.html'),
53 | link: (scope, elem, attrs) => {
54 | scope.elem = elem;
55 | scope.tileStyle = {};
56 | scope.tileStyle.marginRight = "4px";
57 | scope.tileStyle.marginBottom = "4px";
58 | scope.tileStyle.float = "left";
59 |
60 | const container = elem.children();
61 | const itemContainer = container.children().eq(0);
62 |
63 | let linkFunction;
64 |
65 | let heightStart = 0;
66 | let heightEnd = 0;
67 |
68 | let startRow = 0, endRow;
69 | let renderedStartRow = -1, renderedEndRow = -1;
70 |
71 | let itemsPerRow;
72 | let rowCount;
73 | let cachedRowCount;
74 |
75 | let virtualRows = [];
76 | const scopes = {};
77 | let scopeCounter = 0;
78 |
79 | function nextScopeId() {
80 | scopeCounter = scopeCounter + 1;
81 | return 'scope-' + scopeCounter;
82 | }
83 |
84 | function handleTileSizeChange() {
85 | forEachElement(el => {
86 | el.css('width', scope.options.tileSize.width + 'px');
87 | el.css('height', scope.options.tileSize.height + 'px');
88 | });
89 | }
90 |
91 | function handleTemplateUrlChange() {
92 | const template = $templateCache.get(scope.options.templateUrl);
93 | if (template !== undefined) {
94 | linkFunction = $compile(template);
95 | removeAll();
96 | } else {
97 | console.error('Template url not found: ' + scope.options.templateUrl);
98 | }
99 | }
100 |
101 | function handleAlignHorizontalChange() {
102 | if (scope.options.alignHorizontal) {
103 | sizeDimension = 'width';
104 | minSizeDimension = 'min-width';
105 | orthogonalDimension = 'min-height';
106 | elem.children().addClass('horizontal');
107 | } else {
108 | sizeDimension = 'height';
109 | minSizeDimension = 'min-height';
110 | orthogonalDimension = 'min-width';
111 | elem.children().removeClass('horizontal');
112 | }
113 | }
114 |
115 | scope.$watch('options', (options, currentOptions) => {
116 | // set defaults:
117 | options.scrollEndOffset = def(options.scrollEndOffset, 0);
118 | options.overflow = def(options.overflow, 2);
119 | options.debounce = def(options.debounce, 0);
120 | options.afterScrollDelay = def(options.afterScrollDelay, 100);
121 |
122 | if (options === currentOptions || options.templateUrl !== currentOptions.templateUrl) {
123 | handleTemplateUrlChange();
124 | }
125 | if (options === currentOptions || options.alignHorizontal !== currentOptions.alignHorizontal) {
126 | handleAlignHorizontalChange();
127 | }
128 | layout(true);
129 | if (options === currentOptions || options.tileSize.width !== currentOptions.tileSize.width || options.tileSize.height !== currentOptions.tileSize.height) {
130 | handleTileSizeChange();
131 | }
132 | }, true);
133 |
134 | var sizeDimension, minSizeDimension, orthogonalDimension;
135 | scope.$watchCollection('items', () => {
136 | lastScrollPosition = Number.NEGATIVE_INFINITY;
137 | layout(true);
138 | });
139 |
140 | let resizeTimeout;
141 | scope.$on('td.tileview.resize', () => {
142 | // this might be called within a $digest
143 | if (resizeTimeout) {
144 | $timeout.cancel(resizeTimeout);
145 | }
146 | resizeTimeout = $timeout(resize, 50, false);
147 | });
148 | scope.$on('td.tileview.update', () => {
149 | layout(true);
150 | });
151 |
152 | angular.element($window).on('resize', onResize);
153 |
154 | scope.$on('$destroy', function () {
155 | angular.element($window).off('resize', onResize);
156 |
157 | // unregister all timers:
158 | if (resizeTimeout !== undefined) {
159 | $timeout.cancel(resizeTimeout);
160 | }
161 | if (scrollEndTimeout !== undefined) {
162 | $timeout.cancel(scrollEndTimeout);
163 | }
164 | if (debounceTimeout !== undefined) {
165 | $timeout.cancel(debounceTimeout);
166 | }
167 |
168 | removeAll();
169 | });
170 |
171 | function removeElement(el) {
172 | const id = el.attr('id');
173 | if (scopes[id] !== undefined) {
174 | scopes[id].$destroy();
175 | delete scopes[id];
176 | }
177 | el.remove();
178 | }
179 |
180 | function removeAll() {
181 | forEachRow(removeRow);
182 | }
183 |
184 | function forEachElement(fn) {
185 | forEachRow((row, rowIndex) => {
186 | for (let i = 0; i < row.children().length; ++i) {
187 | fn(row.children().eq(i), rowIndex*itemsPerRow + i);
188 | }
189 | });
190 | }
191 |
192 | function forEachRow(fn) {
193 | const numOfRows = visibleRowCount();
194 | for (let i = 0; i < numOfRows; ++i) {
195 | fn(itemContainer.children().eq(i), startRow + i);
196 | }
197 | }
198 |
199 | function visibleRowCount() {
200 | return itemContainer.children().length;
201 | }
202 |
203 | function itemElementCount() {
204 | return visibleRowCount() * itemsPerRow;
205 | }
206 |
207 | let lastScrollPosition = Number.NEGATIVE_INFINITY;
208 | function updateVisibleRows() {
209 | function clamp(value, min, max) {
210 | return Math.max(Math.min(value, max), min);
211 | }
212 |
213 | const rect = container[0].getBoundingClientRect();
214 | const itemSize = scope.options.tileSize[sizeDimension];
215 |
216 | const maxScrollPosition = rowCount * itemSize - rect[sizeDimension];
217 |
218 | let scrollPosition = scope.options.alignHorizontal ?
219 | container.scrollLeft() :
220 | container[0].scrollTop;
221 |
222 | const scrollEndThreshold = maxScrollPosition - scope.options.scrollEndOffset * itemSize;
223 | if (scrollPosition >= scrollEndThreshold && !(lastScrollPosition >= scrollEndThreshold) && scope.options.onScrollEnd !== undefined) {
224 | scope.options.onScrollEnd();
225 | }
226 |
227 | startRow = clamp(Math.floor(scrollPosition / itemSize) - scope.options.overflow, 0, rowCount - cachedRowCount);
228 | endRow = startRow + cachedRowCount;
229 | lastScrollPosition = scrollPosition;
230 | }
231 |
232 | function updateItem(elem, index, digest) {
233 | const item = scope.items[index];
234 | if (item !== undefined) {
235 | if (elem.css('display') === 'none') {
236 | elem.css('display', 'inline-block');
237 | }
238 | const itemScope = scopes[elem.attr('id')];
239 | itemScope.item = item;
240 | itemScope.$index = index;
241 | if (digest === true) {
242 | itemScope.$digest();
243 | }
244 | } else {
245 | elem.css('display', 'none');
246 | }
247 | }
248 |
249 | function updateRow(el, rowIndex, digest) {
250 | const ch = el.children();
251 | for (let i = 0; i < ch.length; ++i) {
252 | updateItem(ch.eq(i), rowIndex * itemsPerRow + i, digest);
253 | }
254 | let translate = Math.max(rowIndex * scope.options.tileSize[sizeDimension], 0);
255 | //el.css('transform', `${translate}(${Math.max(rowIndex * scope.options.tileSize[sizeDimension], 0)}px), translateZ(${rowIndex})`);
256 | if (scope.options.alignHorizontal) {
257 | if (itemContainer.direction() === 'rtl') {
258 | translate = -translate;
259 | }
260 | el.css('transform', `translate3d(${translate}px, 0px, 0)`);
261 | } else {
262 | el.css('transform', `translate3d(0px, ${translate}px, 0)`);
263 | }
264 | }
265 |
266 | function addRow() {
267 | const row = angular.element('
');
268 | row.css('position', 'absolute');
269 | itemContainer.append(row);
270 | return row;
271 | }
272 |
273 | function clearRow(row) {
274 | while (row.children().length > 0) {
275 | removeElementFromRow(row);
276 | }
277 | }
278 |
279 | function removeRow() {
280 | const row = itemContainer.children().eq(-1);
281 | clearRow(row);
282 | row.remove();
283 | }
284 |
285 | function addElementToRow(row) {
286 | const newScope = scope.$parent.$new();
287 | linkFunction(newScope, function (clonedElement) {
288 | clonedElement.css({
289 | width: scope.options.tileSize.width + 'px',
290 | height: scope.options.tileSize.height + 'px',
291 | display: 'inline-block',
292 | 'vertical-align': 'top'
293 | });
294 | const scopeId = nextScopeId();
295 | clonedElement.attr('id', scopeId);
296 | scopes[scopeId] = newScope;
297 | row.append(clonedElement);
298 | });
299 | }
300 |
301 | function fillRow(row) {
302 | const currentRowLength = row.children().length;
303 | if (currentRowLength < itemsPerRow) {
304 | for (let i = currentRowLength; i < itemsPerRow; ++i) {
305 | addElementToRow(row);
306 | }
307 | } else if (currentRowLength > itemsPerRow) {
308 | for (let i = currentRowLength; i > itemsPerRow; --i) {
309 | removeElementFromRow(row);
310 | }
311 | }
312 | }
313 |
314 | function removeElementFromRow(row) {
315 | removeElement(row.children().eq(-1));
316 | }
317 |
318 | function createElements(numRows) {
319 | updateVisibleRows();
320 | const currentRowCount = itemContainer.children().length;
321 |
322 | if (currentRowCount < numRows) {
323 | for (let i = currentRowCount; i < numRows; ++i) {
324 | addRow();
325 | }
326 | } else if (currentRowCount > numRows) {
327 | for (let i = currentRowCount; i > numRows; --i) {
328 | removeRow();
329 | }
330 | }
331 |
332 | forEachRow(fillRow);
333 |
334 | virtualRows = [];
335 | const startIndex = startRow * itemsPerRow;
336 | forEachRow((el, i) => {
337 | virtualRows.push(el);
338 | updateRow(el, i, false);
339 | });
340 | renderedStartRow = startRow;
341 | renderedEndRow = endRow;
342 | }
343 |
344 | function resize() {
345 | const newComponentSize = container[0].getBoundingClientRect();
346 | if (newComponentSize.width !== componentWidth || newComponentSize.height !== componentHeight) {
347 | if (layout(false)) {
348 | forEachElement(el => scopes[el.attr('id')].$digest());
349 | }
350 | }
351 | }
352 |
353 | function onResize() {
354 | resize();
355 | }
356 |
357 | function measure() {
358 | const rect = container[0].getBoundingClientRect();
359 | componentWidth = rect.width;
360 | componentHeight = rect.height;
361 | const itemWidth = scope.options.tileSize.width;
362 | const width = rect.width;
363 | const size = rect[sizeDimension];
364 |
365 | const newItemsPerRow = (scope.options.alignHorizontal) ? 1 : Math.floor(width / itemWidth);
366 | const newCachedRowCount = Math.ceil(size / scope.options.tileSize[sizeDimension]) + scope.options.overflow * 2;
367 |
368 | const changes = newItemsPerRow !== itemsPerRow || newCachedRowCount !== cachedRowCount;
369 | itemsPerRow = Math.max(newItemsPerRow, 1);
370 | cachedRowCount = newCachedRowCount;
371 | rowCount = Math.ceil(scope.items.length / itemsPerRow);
372 | return changes;
373 | }
374 |
375 | let componentWidth = 0, componentHeight = 0;
376 | function layout(alwaysLayout) {
377 | if (linkFunction !== undefined && scope.items !== undefined && sizeDimension !== undefined) {
378 | if (measure() || alwaysLayout) {
379 | createElements(cachedRowCount);
380 |
381 | itemContainer.css(minSizeDimension, rowCount * scope.options.tileSize[sizeDimension] + 'px');
382 | itemContainer.css(orthogonalDimension, '100%');
383 | //setPlaceholder();
384 | scope.$parent.$broadcast('td.tileview.layout');
385 | return true;
386 | }
387 | }
388 | return false;
389 | }
390 |
391 | function update() {
392 | updateVisibleRows();
393 | animationFrameRequested = false;
394 |
395 | if (startRow !== renderedStartRow || endRow !== renderedEndRow) {
396 | if (startRow > renderedEndRow || endRow < renderedStartRow) {
397 | virtualRows.forEach((el, i) => updateRow(el, startRow + i, true));
398 | //forEachRow((el, i) => updateRow(el, startRow + i, true));
399 | } else {
400 | const intersectionStart = Math.max(startRow, renderedStartRow);
401 | const intersectionEnd = Math.min(endRow, renderedEndRow);
402 | if (endRow > intersectionEnd) {
403 | // scrolling downwards
404 | for (let i = intersectionEnd; i < endRow; ++i) {
405 | const e = virtualRows.shift();
406 | updateRow(e, i, true);
407 | virtualRows.push(e);
408 | }
409 | } else if (startRow < intersectionStart) {
410 | // scrolling upwards
411 | for (let i = intersectionStart - 1; i >= startRow; --i) {
412 | const e = virtualRows.pop();
413 | updateRow(e, i, true);
414 | virtualRows.unshift(e);
415 | }
416 | }
417 | }
418 |
419 | renderedStartRow = startRow;
420 | renderedEndRow = endRow;
421 | }
422 | }
423 |
424 | function detectScrollStartEnd() {
425 | if (scope.options.afterScrollDelay !== undefined) {
426 | if (scrollEndTimeout !== undefined) {
427 | $timeout.cancel(scrollEndTimeout);
428 | } else {
429 | scope.$parent.$broadcast('td.tileview.scrollStart');
430 | }
431 | scrollEndTimeout = $timeout(() => {
432 | // scrolling ends:
433 | scrollEndTimeout = undefined;
434 | scope.$parent.$broadcast('td.tileview.scrollEnd');
435 | }, scope.options.afterScrollDelay, false);
436 | }
437 | }
438 |
439 | let debounceTimeout, scrollEndTimeout;
440 | let animationFrameRequested = false;
441 | function onScroll() {
442 | detectScrollStartEnd();
443 | if (scope.options.debounce !== undefined && scope.options.debounce > 0) {
444 | if (debounceTimeout === undefined) {
445 | debounceTimeout = $timeout(function () {
446 | debounceTimeout = undefined;
447 | update();
448 | }, scope.options.debounce, false);
449 | }
450 | } else {
451 | if (!animationFrameRequested) {
452 | animationFrameRequested = true;
453 | requestAnimationFrame(update);
454 | }
455 | }
456 | }
457 |
458 | container.on('scroll', onScroll);
459 | }
460 | };
461 | }]);
462 |
463 | // Helper functions:
464 | function def(value, defaultValue) {
465 | return (value !== undefined) ? value : defaultValue;
466 | }
467 |
468 | })();
--------------------------------------------------------------------------------