1 include('scrollPane.js');
  2 
  3 uki.view.list = {};
  4 /**
  5  * List View
  6  * Progressevly renders list data. Support selection and drag&drop.
  7  * Renders rows with plain html.
  8  * 
  9  * @author voloko
 10  * @name uki.view.List
 11  * @class
 12  * @extends uki.view.Base
 13  * @implements uki.view.Focusable
 14  */
 15 uki.view.declare('uki.view.List', uki.view.Base, uki.view.Focusable, function(Base, Focusable) {
 16     
 17     this._throttle = 42; // do not try to render more often than every 42ms
 18     this._visibleRectExt = 300; // extend visible rect by 300 px overflow
 19     this._defaultBackground = 'theme(list)';
 20     
 21     this._setup = function() {
 22         Base._setup.call(this);
 23         uki.extend(this, {
 24             _rowHeight: 30,
 25             _render: new uki.view.list.Render(),
 26             _data: [],
 27             _lastClickIndex: -1,
 28             _selectedIndexes: []
 29         });
 30     };
 31     
 32     /**
 33      * @function
 34      * @name uki.view.List#defaultBackground
 35      */
 36     this.defaultBackground = function() {
 37         return uki.theme.background('list', this._rowHeight);
 38     };
 39     
 40     /**
 41     * @type uki.view.list.Render
 42     * @function
 43     * @name uki.view.List#render
 44     */
 45     /**
 46     * @function
 47     * @name uki.view.List#packSize
 48     */
 49     /**
 50     * @function
 51     * @name uki.view.List#visibleRectExt
 52     */
 53     /**
 54     * @function
 55     * @name uki.view.List#throttle
 56     */
 57     /**
 58     * @function
 59     * @name uki.view.List#lastClickIndex
 60     */
 61     /**
 62     * @function
 63     * @name uki.view.List#multiselect
 64     */
 65     uki.addProps(this, ['render', 'packSize', 'visibleRectExt', 'throttle', 'lastClickIndex', 'multiselect']);
 66     
 67     /**
 68     * @function
 69     * @name uki.view.List#rowHeight
 70     */
 71     this.rowHeight = uki.newProp('_rowHeight', function(val) {
 72         this._rowHeight = val;
 73         this.minSize(new Size(this.minSize().width, this._rowHeight * this._data.length));
 74         if (this._background) this._background.detach();
 75         this._background = null;
 76         if (this.background()) this.background().attachTo(this);
 77         this._contentChanged();
 78     });
 79     
 80     /**
 81     * @example list.data(['row1', 'row2', ...])
 82     * @function
 83     * @name uki.view.List#data
 84     */
 85     this.data = function(d) {
 86         if (d === undefined) return this._data;
 87         this.clearSelection();
 88         this._data = d;
 89         this._packs[0].itemFrom = this._packs[0].itemTo = this._packs[1].itemFrom = this._packs[1].itemTo = 0;
 90         
 91         this.minSize(new Size(this.minSize().width, this._rowHeight * this._data.length));
 92         this.trigger('selection', {source: this});
 93         this._contentChanged();
 94         return this;
 95     };
 96     
 97     /**
 98     * Forces list content update
 99     * @function
100     * @name uki.view.List#relayout
101     */
102     this.relayout = function() {
103         this._packs[0].itemFrom = this._packs[0].itemTo = this._packs[1].itemFrom = this._packs[1].itemTo = 0;
104         this.layout();
105     };
106     
107     this.contentsSize = function() {
108         return new Size(this.rect().width, this._rowHeight * this._data.length);
109     };
110     
111     /**
112     * used in search. should be fast
113     * @function
114     * @param {Number} position
115     * @param {String} data
116     * @name uki.view.List#addRow
117     */
118     this.addRow = function(position, data) {
119         this._data.splice(position, 0, data);
120         var item = this._itemAt(position);
121         var container = doc.createElement('div');
122         
123         container.innerHTML = this._rowTemplate.render({ 
124             height: this._rowHeight, 
125             text: this._render.render(this._data[position], this._rowRect(position), position)
126         });
127         if (item) {
128             item.parentNode.insertBefore(container.firstChild, item);
129         } else {
130             this._dom.childNodes[0].appendChild(container.firstChild);
131         }
132 
133         if (position <= this._packs[0].itemTo) {
134             this._packs[0].itemTo++;
135             this._packs[1].itemFrom++;
136             this._packs[1].itemTo++;
137             this._packs[1].dom.style.top = this._packs[1].itemFrom*this._rowHeight + 'px';
138         } else {
139             this._packs[1].itemTo++;
140         }
141         
142         // offset selection
143         var selectionPosition = uki.binarySearch(position, this.selectedIndexes());
144         for (var i = selectionPosition; i < this._selectedIndexes.length; i++) {
145             this._selectedIndexes[i]++;
146         };
147         
148         // needed for scrollbar
149         this.minSize(new Size(this.minSize().width, this._rowHeight * this._data.length));
150         this._contentChanged();
151 
152         return this;
153     };
154     
155     /**
156     * @function
157     * @param {Number} position
158     * @name uki.view.List#removeRow
159     */
160     this.removeRow = function(position) {
161         this._data.splice(position, 1);
162         this.data(this._data);
163         return this;
164     };
165     
166     /**
167     * Forces one particular row to be redrawn
168     * @function
169     * @param {Number} position
170     * @name uki.view.List#removeRow
171     */
172     this.redrawRow = function(position) {
173         var item = this._itemAt(position);
174         if (item) item.innerHTML = this._render.render(this._data[position], this._rowRect(position), position);
175         return this;
176     };
177     
178     /**
179     * Read/write current selected index for selectable lists
180     * @function
181     * @param {Number} position
182     * @name uki.view.List#selectedIndex
183     */
184     this.selectedIndex = function(position) {
185         if (position === undefined) return this._selectedIndexes.length ? this._selectedIndexes[0] : -1;
186         this.selectedIndexes([position]);
187         this._scrollToPosition(position);
188         return this;
189     };
190     
191     /**
192     * Read/write all selected indexes for multiselectable lists
193     * @function
194     * @param {Array.<Number>} position
195     * @name uki.view.List#selectedIndex
196     */
197     this.selectedIndexes = function(indexes) {
198         if (indexes === undefined) return this._selectedIndexes;
199         this.clearSelection(true);
200         this._selectedIndexes = indexes;
201         for (var i=0; i < this._selectedIndexes.length; i++) {
202             this._setSelected(this._selectedIndexes[i], true);
203         };
204         this.trigger('selection', {source: this});
205         return this;
206     };
207     
208     /**
209     * Read contents of selected row
210     * @function
211     * @name uki.view.List#selectedRow
212     */
213     this.selectedRow = function() {
214         return this._data[this.selectedIndex()];
215     };    
216     
217     /**
218     * Read contents of all selected rows
219     * @function
220     * @name uki.view.List#selectedRows
221     */
222     this.selectedRows = function() {
223         return uki.map(this.selectedIndexes(), function(index) {
224             return this._data[index];
225         }, this)
226     };
227     
228     /**
229     * @function
230     * @name uki.view.List#clearSelection
231     */
232     this.clearSelection = function(skipClickIndex) {
233         for (var i=0; i < this._selectedIndexes.length; i++) {
234             this._setSelected(this._selectedIndexes[i], false);
235         };
236         this._selectedIndexes = [];
237         if (!skipClickIndex) this._lastClickIndex = -1;
238     };
239     
240     /**
241     * @function
242     * @param {Number} index
243     * @name uki.view.List#isSelected
244     */
245     this.isSelected = function(index) {
246         var found = uki.binarySearch(index, this._selectedIndexes);
247         return this._selectedIndexes[found] == index;
248     };
249     
250     this.layout = function() {
251         this._layoutDom(this._rect);
252         this._needsLayout = false;
253         // send visibleRect with layout
254         this.trigger('layout', { rect: this._rect, source: this, visibleRect: this._visibleRect });
255         this._firstLayout = false;
256     };
257     
258     function range (from, to) {
259         var result = new Array(to - from);
260         for (var idx = 0; from <= to; from++, idx++) {
261             result[idx] = from;
262         };
263         return result;
264     }
265     
266     function removeRange (array, from, to) {
267         var p = uki.binarySearch(from, array),
268             initialP = p;
269         while (array[p] <= to) p++;
270         if (p > initialP) array.splice(initialP, p - initialP);
271     }
272     
273     this._rowRect = function(p) {
274         return new Rect(0, p*this._rowHeight, this.rect().width, this._rowHeight);
275     };
276     
277     this._toggleSelection = function(p) {
278         var indexes = [].concat(this._selectedIndexes);
279         var addTo = uki.binarySearch(p, indexes);
280         if (indexes[addTo] == p) {
281             indexes.splice(addTo, 1);
282         } else {
283             indexes.splice(addTo, 0, p);
284         }
285         this.selectedIndexes(indexes);
286     };
287     
288     var updatingScroll = false;
289     this._scrollableParentScroll = function() {
290         if (updatingScroll) return;
291         if (this._throttle) {
292             if (this._throttleStarted) return;
293             this._throttleStarted = true;
294             setTimeout(uki.proxy(function() {
295                 this._throttleStarted = false;
296                 this.layout();
297             }, this), this._throttle);
298         } else {
299             this.layout();
300         }
301     };
302     
303     this._contentChanged = function() {
304         this._needsLayout = true;
305         uki.after(uki.proxy(this._relayoutParent, this));
306     };
307 
308     this._relayoutParent = function() {
309         this.parent().childResized(this);
310         if (!this._scrollableParent) return;
311         var c = this;
312         while ( c && c != this._scrollableParent) {
313             c._needsLayout = true;
314             c = c.parent();
315         }
316         c.layout();
317     };
318     
319     
320     this.keyPressEvent = function() {
321         var useKeyPress = root.opera || (/mozilla/i.test( ua ) && !(/(compatible|webkit)/i).test( ua ));
322         return useKeyPress ? 'keypress' : 'keydown';
323     };
324     
325     this._bindSelectionEvents = function() {
326         this.bind('mousedown', this._mousedown);
327         this.bind('mouseup', this._mouseup);
328         this.bind(this.keyPressEvent(), this._keypress);
329     };
330     
331     this._mouseup = function(e) {
332         if (!this._multiselect) return;
333         
334         var o = uki.dom.offset(this._dom),
335             y = e.pageY - o.y,
336             p = y / this._rowHeight << 0;
337             
338         if (this._selectionInProcess && this._lastClickIndex == p && this.isSelected(p)) this.selectedIndexes([p]);
339         this._selectionInProcess = false;
340     };
341     
342     this._mousedown = function(e) {
343         var o = uki.dom.offset(this._dom),
344             y = e.pageY - o.y,
345             p = y / this._rowHeight << 0,
346             indexes = this._selectedIndexes;
347 
348         if (this._multiselect) {
349             this._selectionInProcess = false;
350             if (e.shiftKey && indexes.length > 0) {
351                 if (this.isSelected(p)) {
352                     indexes = [].concat(indexes);
353                     removeRange(indexes, Math.min(p+1, this._lastClickIndex), Math.max(p-1, this._lastClickIndex));
354                     this.selectedIndexes(indexes);
355                 } else {
356                     this.selectedIndexes(range(
357                         Math.min(p, indexes[0]),
358                         Math.max(p, indexes[indexes.length - 1])
359                     ));
360                 }
361             } else if (e.metaKey) {
362                 this._toggleSelection(p);
363             } else {
364                 if (!this.isSelected(p)) {
365                     this.selectedIndexes([p]);
366                 } else {
367                     this._selectionInProcess = true;
368                 }
369             }
370         } else {
371             this.selectedIndexes([p]);
372         }
373         this._lastClickIndex = p;
374     };    
375     
376     this._keypress = function(e) {
377         var indexes = this._selectedIndexes,
378             nextIndex = -1;
379         if (e.which == 38 || e.keyCode == 38) { // UP
380             nextIndex = Math.max(0, this._lastClickIndex - 1);
381             e.preventDefault();
382         } else if (e.which == 40 || e.keyCode == 40) { // DOWN
383             nextIndex = Math.min(this._data.length-1, this._lastClickIndex + 1);
384             e.preventDefault();
385         } else if (this._multiselect && (e.which == 97 || e.which == 65) && e.metaKey) {
386             e.preventDefault();
387             this.selectedIndexes(range(0, this._data.length -1));
388         }
389         if (nextIndex > -1 && nextIndex != this._lastClickIndex) {
390             if (e.shiftKey && this._multiselect) {
391                 if (this.isSelected(nextIndex)) {
392                     this._toggleSelection(this._lastClickIndex);
393                 } else {
394                     this._toggleSelection(nextIndex);
395                 }
396                 this._scrollToPosition(nextIndex);
397             } else {
398                 this.selectedIndex(nextIndex);
399             }
400             this._lastClickIndex = nextIndex;
401         }
402     };
403     
404     this._createDom = function() {
405         this._dom = uki.createElement('div', this.defaultCss + 'overflow:hidden');
406         this._initClassName();
407         
408         var packDom = uki.createElement('div', 'position:absolute;left:0;top:0px;width:100%;overflow:hidden');
409         this._packs = [
410             {
411                 dom: packDom,
412                 itemTo: 0,
413                 itemFrom: 0
414             },
415             {
416                 dom: packDom.cloneNode(false),
417                 itemTo: 0,
418                 itemFrom: 0
419             }
420         ];
421         this._dom.appendChild(this._packs[0].dom);
422         this._dom.appendChild(this._packs[1].dom);
423         
424         this._initFocusable();
425         this._bindSelectionEvents();
426     };
427     
428     this._setSelected = function(position, state) {
429         var item = this._itemAt(position);
430         if (item) this._render.setSelected(item, this._data[position], state, this.hasFocus());
431     };
432     
433     this._scrollToPosition = function(position) {
434         if (!this._visibleRect) return;
435         var maxY, minY;
436         maxY = (position+1)*this._rowHeight;
437         minY = position*this._rowHeight;
438         updatingScroll = true;
439         if (maxY >= this._visibleRect.maxY()) {
440             this._scrollableParent.scroll(0, maxY - this._visibleRect.maxY());
441         } else if (minY < this._visibleRect.y) {
442             this._scrollableParent.scroll(0, minY - this._visibleRect.y);
443         }
444         updatingScroll = false;
445         this.layout();
446     };
447     
448     this._itemAt = function(position) {
449         if (position < this._packs[1].itemTo && position >= this._packs[1].itemFrom) {
450             return this._packs[1].dom.childNodes[position - this._packs[1].itemFrom];
451         } else if (position < this._packs[0].itemTo && position >= this._packs[0].itemFrom) {
452             return this._packs[0].dom.childNodes[position - this._packs[0].itemFrom];
453         }
454         return null;
455     };
456     
457     this._rowTemplate = new uki.theme.Template('<div style="width:100%;height:${height}px;overflow:hidden;">${text}</div>')
458     
459     this._renderPack = function(pack, itemFrom, itemTo) {
460         var html = [], position;
461         for (i=itemFrom; i < itemTo; i++) {
462             html[html.length] = this._rowTemplate.render({ 
463                 height: this._rowHeight, 
464                 text: this._render.render(this._data[i], this._rowRect(i), i)
465             });
466         };
467         pack.dom.innerHTML = html.join('');
468         pack.itemFrom = itemFrom;
469         pack.itemTo   = itemTo;
470         pack.dom.style.top = itemFrom*this._rowHeight + 'px';
471         this._restorePackSelection(pack, itemFrom, itemTo);
472     };
473     
474     //   xxxxx    |    xxxxx  |  xxxxxxxx  |     xxx
475     //     yyyyy  |  yyyyy    |    yyyy    |   yyyyyyy
476     this._restorePackSelection = function(pack) {
477         var indexes = this._selectedIndexes;
478         
479         if (
480             (indexes[0] <= pack.itemFrom && indexes[indexes.length - 1] >= pack.itemFrom) || // left index
481             (indexes[0] <= pack.itemTo   && indexes[indexes.length - 1] >= pack.itemTo) || // right index
482             (indexes[0] >= pack.itemFrom && indexes[indexes.length - 1] <= pack.itemTo) // within
483         ) {
484             var currentSelection = uki.binarySearch(pack.itemFrom, indexes);
485             currentSelection = Math.max(currentSelection, 0);
486             while(indexes[currentSelection] !== null && indexes[currentSelection] < pack.itemTo) {
487                 var position = indexes[currentSelection] - pack.itemFrom;
488                 this._render.setSelected(pack.dom.childNodes[position], this._data[position], true, this.hasFocus());
489                 currentSelection++;
490             }
491         }
492     };
493     
494     this._swapPacks = function() {
495         var tmp = this._packs[0];
496         this._packs[0] = this._packs[1];
497         this._packs[1] = tmp;
498     };
499     
500     this._layoutDom = function(rect) {
501         if (!this._scrollableParent) {
502             this._scrollableParent = uki.view.scrollableParent(this);
503             this._scrollableParent.bind('scroll', uki.proxy(this._scrollableParentScroll, this));
504         }
505         
506         var totalHeight = this._rowHeight * this._data.length,
507             scrollableParent = this._scrollableParent;
508 
509         this._visibleRect = uki.view.visibleRect(this, scrollableParent);
510         if (this._focusTarget) this._focusTarget.style.top = this._visibleRect.y + 'px';
511         var prefferedPackSize = CEIL((this._visibleRect.height + this._visibleRectExt*2) / this._rowHeight),
512         
513             minVisibleY  = MAX(0, this._visibleRect.y - this._visibleRectExt),
514             maxVisibleY  = MIN(totalHeight, this._visibleRect.maxY() + this._visibleRectExt),
515             minRenderedY = this._packs[0].itemFrom * this._rowHeight,
516             maxRenderedY = this._packs[1].itemTo * this._rowHeight,
517             
518             itemFrom, itemTo, startAt, updated = true;
519 
520         Base._layoutDom.call(this, rect);
521         if (
522             maxVisibleY <= minRenderedY || minVisibleY >= maxRenderedY || // both packs below/above visible area
523             (maxVisibleY > maxRenderedY && this._packs[1].itemFrom * this._rowHeight > this._visibleRect.y && this._packs[1].itemTo > this._packs[1].itemFrom) || // need to render below, and pack 2 is not enough to cover
524             (minVisibleY < minRenderedY && this._packs[0].itemTo * this._rowHeight < this._visibleRect.maxY()) // need to render above, and pack 1 is not enough to cover the area
525             // || prefferedPackSize is not enough to cover the area above/below, can this actually happen?
526         ) { 
527             // this happens a) on first render b) on scroll jumps c) on container resize
528             // render both packs, move them to be at the center of visible area
529             // startAt = minVisibleY + (maxVisibleY - minVisibleY - prefferedPackSize*this._rowHeight*2) / 2;
530             startAt = minVisibleY - this._visibleRectExt / 2;
531             itemFrom = MAX(0, Math.round(startAt / this._rowHeight));
532             itemTo = MIN(this._data.length, itemFrom + prefferedPackSize);
533             
534             this._renderPack(this._packs[0], itemFrom, itemTo);
535             this._renderPack(this._packs[1], itemTo, itemTo);
536             // this._renderPack(this._packs[1], itemTo, MIN(this._data.length, itemTo + prefferedPackSize));
537         } else if (maxVisibleY > maxRenderedY && this._packs[1].itemTo > this._packs[1].itemFrom) { // we need to render below current area
538             // this happens on normal scroll down
539             // re-render bottom, swap
540             itemFrom = this._packs[1].itemTo;
541             itemTo   = MIN(this._data.length, this._packs[1].itemTo + prefferedPackSize);
542             
543             this._renderPack(this._packs[0], itemFrom, itemTo);
544             this._swapPacks();
545         } else if (maxVisibleY > maxRenderedY) { // we need to render below current area
546             itemFrom = this._packs[0].itemTo;
547             itemTo   = MIN(this._data.length, this._packs[1].itemTo + prefferedPackSize);
548             
549             this._renderPack(this._packs[1], itemFrom, itemTo);
550         } else if (minVisibleY < minRenderedY) { // we need to render above current area
551             // this happens on normal scroll up
552             // re-render top, swap
553             itemFrom = MAX(this._packs[0].itemFrom - prefferedPackSize, 0);
554             itemTo   = this._packs[0].itemFrom;
555             
556             this._renderPack(this._packs[1], itemFrom, itemTo);
557             this._swapPacks();
558         } else {
559             updated = false;
560         }
561         if (updated && /MSIE 6|7/.test(ua)) this.dom().className += '';
562     };
563     
564     this._bindToDom = function(name) {
565         return Focusable._bindToDom.call(this, name) || Base._bindToDom.call(this, name);
566     };
567     
568     this._focus = function(e) {
569         Focusable._focus.call(this, e);
570         if (this._selectedIndexes.length == 0 && this._data.length > 0) {
571             this.selectedIndexes([0]);
572         } else {
573             this.selectedIndexes(this.selectedIndexes());
574         }
575     };
576     
577     this._blur = function(e) {
578         Focusable._blur.call(this, e);
579         this.selectedIndexes(this.selectedIndexes());
580     };
581     
582 });
583 
584 /** @function
585 @name uki.Collection#data */
586 /** @function
587 @name uki.Collection#selectedIndex */
588 /** @function
589 @name uki.Collection#selectedIndexes */
590 /** @function
591 @name uki.Collection#selectedRow */
592 /** @function
593 @name uki.Collection#selectedRows */
594 /** @function
595 @name uki.Collection#lastClickIndex */
596 uki.Collection.addAttrs(['data', 'selectedIndex', 'selectedIndexes', 'selectedRow', 'selectedRows', 'lastClickIndex']);
597 
598 /**
599  * Scrollable List View
600  * Puts a list into a scroll pane
601  * 
602  * @author voloko
603  * @name uki.view.ScrollableList
604  * @class
605  * @extends uki.view.ScrollPane
606  */
607 uki.view.declare('uki.view.ScrollableList', uki.view.ScrollPane, function(Base) {
608 
609     this._createDom = function() {
610         Base._createDom.call(this);
611         this._list = uki({ view: 'List', rect: this.rect().clone().normalize(), anchors: 'left top right bottom' })[0];
612         this.appendChild(this._list);
613     };
614     
615     uki.each('data rowHeight render packSize visibleRectExt throttle focusable selectedIndex selectedIndexes selectedRow selectedRows multiselect draggable textSelectable'.split(' '), 
616         function(i, name) {
617             uki.delegateProp(this, name, '_list');
618         }, this);
619     
620 });
621 
622 include('list/render.js');
623