diff options
author | arv@chromium.org <arv@chromium.org@0039d316-1c4b-4281-b951-d872f2087c98> | 2010-06-21 18:58:43 +0000 |
---|---|---|
committer | arv@chromium.org <arv@chromium.org@0039d316-1c4b-4281-b951-d872f2087c98> | 2010-06-21 18:58:43 +0000 |
commit | 630210c7f07792a413f45b1ebc6aa3c0515e57f3 (patch) | |
tree | 29e96794786b6400573056bb816417122ac2ce07 | |
parent | 3c92499fe304b9b65d647e3d78303aab3cd931c9 (diff) | |
download | chromium_src-630210c7f07792a413f45b1ebc6aa3c0515e57f3.zip chromium_src-630210c7f07792a413f45b1ebc6aa3c0515e57f3.tar.gz chromium_src-630210c7f07792a413f45b1ebc6aa3c0515e57f3.tar.bz2 |
Bookmarks/DOMUI JS: Refactor the list to use a data model.
This refactors the cr.ui.List to be backed by a data model. The data model is like an array, but it dispatches events when it changes. When the data model changes the view is updated. The view is smart enough to only draw what is in the viewport which allows large data sets to be displayed.
There are a lot of TODOs after this to make the list more reusable but this is already getting big so I'd rather get this in before moving on.
TODOs:
1. Create a *real* data model for the bookmark list.
2. Move the edit behavior from bmm.BookmarkListItem to cr.ui.ListItem and make it work better with scrolling.
3. Refactor the drag and drop code so that it can be reused.
4. Refactor the selectionModel so that it does not handle the user input events.
BUG=39528
TEST=The bookmarks manager should still work.
Review URL: http://codereview.chromium.org/2842001
git-svn-id: svn://svn.chromium.org/chrome/trunk/src@50369 0039d316-1c4b-4281-b951-d872f2087c98
9 files changed, 1076 insertions, 438 deletions
diff --git a/chrome/browser/resources/bookmark_manager/js/bmm/bookmark_list.js b/chrome/browser/resources/bookmark_manager/js/bmm/bookmark_list.js index acddf41..73345da 100644 --- a/chrome/browser/resources/bookmark_manager/js/bmm/bookmark_list.js +++ b/chrome/browser/resources/bookmark_manager/js/bmm/bookmark_list.js @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// TODO(arv): Now that this is driven by a data model, implement a data model +// that handles the loading and the events from the bookmark backend. cr.define('bmm', function() { // require cr.ui.define @@ -9,8 +11,37 @@ cr.define('bmm', function() { // require cr.ui.contextMenuHandler const List = cr.ui.List; const ListItem = cr.ui.ListItem; + const ArrayDataModel = cr.ui.ArrayDataModel; - var listLookup = {}; + /** + * Basic array data model for use with bookmarks. + * @param {!Array.<!BookmarkTreeNode>} items The bookmark items. + * @constructor + * @extends {ArrayDataModel} + */ + function BookmarksArrayDataModel(items) { + this.bookmarksArrayDataModelArray_ = items; + ArrayDataModel.call(this, items); + } + + BookmarksArrayDataModel.prototype = { + __proto__: ArrayDataModel.prototype, + + /** + * Finds the index of the bookmark with the given ID. + * @param {string} id The ID of the bookmark node to find. + * @return {number} The index of the found node or -1 if not found. + */ + findIndexById: function(id) { + var arr = this.bookmarksArrayDataModelArray_; + var length = arr.length + for (var i = 0; i < length; i++) { + if (arr[i].id == id) + return i; + } + return -1; + } + }; /** * Removes all children and appends a new child. @@ -36,19 +67,26 @@ cr.define('bmm', function() { BookmarkList.prototype = { __proto__: List.prototype, + /** @inheritDoc */ decorate: function() { List.prototype.decorate.call(this); this.addEventListener('click', this.handleClick_); this.addEventListener('mousedown', this.handleMouseDown_); // HACK(arv): http://crbug.com/40902 - var self = this; - window.addEventListener('resize', function() { - self.fixWidth_(); - }); + window.addEventListener('resize', cr.bind(this.redraw, this)); + }, + + createItem: function(bookmarkNode) { + return new BookmarkListItem(bookmarkNode); }, parentId_: '', + + /** + * The ID of the bookmark folder we are displaying. + * @type {string} + */ get parentId() { return this.parentId_; }, @@ -70,7 +108,7 @@ cr.define('bmm', function() { reload: function() { var parentId = this.parentId; - var callback = cr.bind(this.handleBookmarkCallback, this); + var callback = cr.bind(this.handleBookmarkCallback_, this); this.loading_ = true; if (!parentId) { @@ -84,29 +122,21 @@ cr.define('bmm', function() { } }, - handleBookmarkCallback: function(items) { + /** + * Callback function for loading items. + * @param {Array.<!BookmarkTreeNode>} items The loaded items. + * @private + */ + handleBookmarkCallback_: function(items) { if (!items) { - // Failed to load bookmarks. Most likely due to the bookmark beeing + // Failed to load bookmarks. Most likely due to the bookmark being // removed. cr.dispatchSimpleEvent(this, 'invalidId'); this.loading_ = false; return; } - // Remove all fields without recreating the object since other code - // references it. - for (var id in listLookup){ - delete listLookup[id]; - } - this.clear(); - try { - this.startBatchAdd(); - items.forEach(function(item) { - var li = createListItem(item); - this.add(li); - }, this); - } finally { - this.finishBatchAdd(); - } + + this.dataModel = new BookmarksArrayDataModel(items); this.loading_ = false; this.fixWidth_(); @@ -125,35 +155,20 @@ cr.define('bmm', function() { return treeItem && treeItem.bookmarkNode; }, + /** + * @return {boolean} Whether we are currently showing search results. + */ isSearch: function() { return this.parentId_[0] == 'q'; }, + /** + * @return {boolean} Whether we are currently showing recent bookmakrs. + */ isRecent: function() { return this.parentId_ == 'recent'; }, - /** @inheritDoc */ - addAt: function(item, index) { - // Override to work around list width bug in flex box. - List.prototype.addAt.call(this, item, index); - this.fixWidth_(); - }, - - /** @inheritDoc */ - remove: function(item) { - // Override to work around list width bug in flex box. - List.prototype.remove.call(this, item); - this.fixWidth_(); - }, - - /** @inheritDoc */ - clear: function() { - // Override to work around list width bug in flex box. - List.prototype.clear.call(this); - this.fixWidth_(); - }, - /** * Handles the clicks on the list so that we can check if the user clicked * on a link or a folder. @@ -205,32 +220,39 @@ cr.define('bmm', function() { // Bookmark model update callbacks handleBookmarkChanged: function(id, changeInfo) { - var listItem = listLookup[id]; - if (listItem) { - listItem.bookmarkNode.title = changeInfo.title; + var dataModel = this.dataModel; + var index = dataModel.findIndexById(id); + if (index != -1) { + var bookmarkNode = this.dataModel.item(index); + bookmarkNode.title = changeInfo.title; if ('url' in changeInfo) - listItem.bookmarkNode.url = changeInfo['url']; - updateListItem(listItem, listItem.bookmarkNode); + bookmarkNode.url = changeInfo['url']; + + dataModel.updateIndex(index); } }, handleChildrenReordered: function(id, reorderInfo) { if (this.parentId == id) { - var self = this; - reorderInfo.childIds.forEach(function(id, i) { - var li = listLookup[id] - self.addAt(li, i); - // At this point we do not read the index from the bookmark node so we - // do not need to update it. - li.bookmarkNode.index = i; - }); + // We create a new data model with updated items in the right order. + var dataModel = this.dataModel; + var items = {}; + for (var i = this.dataModel.length -1 ; i >= 0; i--) { + var bookmarkNode = dataModel.item(i); + items[bookmarkNode.id] = bookmarkNode; + } + var newArray = []; + for (var i = 0; i < reorderInfo.childIds.length; i++) { + newArray[i] = items[reorderInfo.childIds[i]]; + } + + this.dataModel = new BookmarksArrayDataModel(newArray); } }, handleCreated: function(id, bookmarkNode) { if (this.parentId == bookmarkNode.parentId) { - var li = createListItem(bookmarkNode, false); - this.addAt(li, bookmarkNode.index); + this.dataModel.splice(bookmarkNode.index, 0, bookmarkNode); } }, @@ -238,27 +260,33 @@ cr.define('bmm', function() { if (moveInfo.parentId == this.parentId || moveInfo.oldParentId == this.parentId) { + var dataModel = this.dataModel; + if (moveInfo.oldParentId == moveInfo.parentId) { - var listItem = listLookup[id]; - if (listItem) { - this.remove(listItem); - this.addAt(listItem, moveInfo.index); - } + // Reorder within this folder + + this.startBatchUpdates(); + + var bookmarkNode = this.dataModel.item(moveInfo.oldIndex); + this.dataModel.splice(moveInfo.oldIndex, 1); + this.dataModel.splice(moveInfo.index, 0, bookmarkNode); + + this.endBatchUpdates(); } else { if (moveInfo.oldParentId == this.parentId) { - var listItem = listLookup[id]; - if (listItem) { - this.remove(listItem); - delete listLookup[id]; - } + // Move out of this folder + + var index = dataModel.findIndexById(id); + if (index != -1) + dataModel.splice(index, 1); } if (moveInfo.parentId == list.parentId) { + // Move to this folder var self = this; chrome.bookmarks.get(id, function(bookmarkNodes) { var bookmarkNode = bookmarkNodes[0]; - var li = createListItem(bookmarkNode, false); - self.addAt(li, bookmarkNode.index); + dataModel.splice(bookmarkNode.index, 0, bookmarkNode); }); } } @@ -266,11 +294,10 @@ cr.define('bmm', function() { }, handleRemoved: function(id, removeInfo) { - var listItem = listLookup[id]; - if (listItem) { - this.remove(listItem); - delete listLookup[id]; - } + var dataModel = this.dataModel; + var index = dataModel.findIndexById(id); + if (index != -1) + dataModel.splice(index, 1); }, /** @@ -304,14 +331,66 @@ cr.define('bmm', function() { /** * Creates a new bookmark list item. - * @param {Object=} opt_propertyBag Optional properties. + * @param {!BookmarkTreeNode} bookmarkNode The bookmark node this represents. * @constructor * @extends {cr.ui.ListItem} */ - var BookmarkListItem = cr.ui.define('li'); + function BookmarkListItem(bookmarkNode) { + var el = cr.doc.createElement('div'); + el.bookmarkNode = bookmarkNode; + BookmarkListItem.decorate(el); + return el; + } + + /** + * Decorates an element as a bookmark list item. + * @param {!HTMLElement} el The element to decorate. + */ + BookmarkListItem.decorate = function(el) { + el.__proto__ = BookmarkListItem.prototype; + el.decorate(); + }; BookmarkListItem.prototype = { __proto__: ListItem.prototype, + + /** @inheritDoc */ + decorate: function() { + ListItem.prototype.decorate.call(this); + + var bookmarkNode = this.bookmarkNode; + + this.draggable = true; + + var labelEl = this.ownerDocument.createElement('span'); + labelEl.className = 'label'; + labelEl.textContent = bookmarkNode.title; + + var urlEl = this.ownerDocument.createElement('span'); + urlEl.className = 'url'; + urlEl.dir = 'ltr'; + + if (bmm.isFolder(bookmarkNode)) { + this.className = 'folder'; + labelEl.href = '#' + bookmarkNode.id; + } else { + labelEl.style.backgroundImage = url('chrome://favicon/' + + bookmarkNode.url); + labelEl.href = urlEl.textContent = bookmarkNode.url; + } + + this.appendChild(labelEl); + this.appendChild(urlEl); + }, + + /** + * The ID of the bookmark folder we are currently showing or loading. + * @type {string} + */ + get bookmarkId() { + return this.bookmarkNode.id; + }, + /** * Whether the user is currently able to edit the list item. * @type {boolean} @@ -464,41 +543,7 @@ cr.define('bmm', function() { } }; - function createListItem(bookmarkNode) { - var li = listItemPromo.cloneNode(true); - BookmarkListItem.decorate(li); - updateListItem(li, bookmarkNode); - li.bookmarkId = bookmarkNode.id; - li.bookmarkNode = bookmarkNode; - li.draggable = true; - listLookup[bookmarkNode.id] = li; - return li; - } - - function updateListItem(el, bookmarkNode) { - var labelEl = el.firstChild; - labelEl.textContent = bookmarkNode.title; - if (!bmm.isFolder(bookmarkNode)) { - labelEl.style.backgroundImage = url('chrome://favicon/' + - bookmarkNode.url); - - var urlEl = el.childNodes[1]; - labelEl.href = urlEl.textContent = bookmarkNode.url; - } else { - labelEl.href = '#' + bookmarkNode.id; - el.className = 'folder'; - } - } - - var listItemPromo = (function() { - var div = cr.doc.createElement('div'); - div.innerHTML = '<span class=label></span><span class=url dir=ltr></span>'; - return div; - })(); - return { - createListItem: createListItem, - BookmarkList: BookmarkList, - listLookup: listLookup + BookmarkList: BookmarkList }; }); diff --git a/chrome/browser/resources/bookmark_manager/main.html b/chrome/browser/resources/bookmark_manager/main.html index 3c58301..5018300 100644 --- a/chrome/browser/resources/bookmark_manager/main.html +++ b/chrome/browser/resources/bookmark_manager/main.html @@ -23,6 +23,7 @@ found in the LICENSE file. <script src="chrome://resources/js/cr/link_controller.js"></script> <script src="chrome://resources/js/cr/promise.js"></script> <script src="chrome://resources/js/cr/ui.js"></script> +<script src="chrome://resources/js/cr/ui/array_data_model.js"></script> <script src="chrome://resources/js/cr/ui/list_selection_model.js"></script> <script src="chrome://resources/js/cr/ui/list_item.js"></script> <script src="chrome://resources/js/cr/ui/list.js"></script> @@ -183,24 +184,9 @@ const ROOT_ID = '0'; var bookmarkCache = { /** - * This returns a reference to the bookmark node that is cached by the tree - * or list. Use this funciton when we need to update the local cache after - * changes. It only returns bookmarks that are used by the tree and/or the - * list. - * @param {string} The ID of the bookmark that we want to get. - * @return {BookmarkTreeNode} - */ - getById: function(id) { - var el = bmm.treeLookup[id] || bmm.listLookup[id]; - return el && el.bookmarkNode; - }, - - /** * Removes the cached item from both the list and tree lookups. */ remove: function(id) { - delete bmm.listLookup[id]; - var treeItem = bmm.treeLookup[id]; if (treeItem) { var items = treeItem.items; // is an HTMLCollection @@ -224,10 +210,6 @@ var bookmarkCache = { if (treeItem) { treeItem.bookmarkNode = bookmarkNode; } - var listItem = bmm.listLookup[bookmarkNode.id]; - if (listItem) { - listItem.bookmarkNode = bookmarkNode; - } } chrome.bookmarks.getChildren(id, function(children) { @@ -758,31 +740,31 @@ var dnd = { handleDragStart: function(e) { // Determine the selected bookmarks. var target = e.target; - var draggedItems = []; + var draggedNodes = []; if (target instanceof ListItem) { // Use selected items. - draggedItems = target.parentNode.selectedItems; + draggedNodes = target.parentNode.selectedItems; } else if (target instanceof TreeItem) { - draggedItems.push(target); + draggedNodes.push(target.bookmarkNode); } // We manage starting the drag by using the extension API. e.preventDefault(); - if (draggedItems.length) { + if (draggedNodes.length) { // If we are dragging a single link we can do the *Link* effect, otherwise // we only allow copy and move. var effectAllowed; - if (draggedItems.length == 1 && - !bmm.isFolder(draggedItems[0].bookmarkNode)) { + if (draggedNodes.length == 1 && + !bmm.isFolder(draggedNodes[0])) { effectAllowed = 'copyMoveLink'; } else { effectAllowed = 'copyMove'; } e.dataTransfer.effectAllowed = effectAllowed; - var ids = draggedItems.map(function(el) { - return el.bookmarkId; + var ids = draggedNodes.map(function(node) { + return node.id; }); chrome.experimental.bookmarkManager.startDrag(ids); @@ -907,11 +889,10 @@ var dnd = { overlayType = 'below'; // Get the rect of the last list item. - var items = overElement.items; - var length = items.length; + var length = overElement.dataModel.length; if (length) { dropPos = 'below'; - overElement = items[length - 1]; + overElement = overElement.getListItemByIndex(length - 1); rect = overElement.getBoundingClientRect(); } else { // If there are no items, collapse the height of the rect @@ -985,12 +966,25 @@ var dnd = { var parentId = dropPos == 'on' ? relatedNode.id : relatedNode.parentId; var index; + var relatedIndex; + // Try to find the index in the dataModel so we don't have to always keep + // the index for the list items up to date. + var overElement = this.getBookmarkElement(e.target); + if (overElement instanceof ListItem) { + relatedIndex = overElement.parentNode.dataModel.indexOf(relatedNode); + } else if (overElement instanceof BookmarkList) { + relatedIndex = overElement.dataModel.length - 1; + } else { + // Tree + relatedIndex = relatedNode.index; + } + if (dropPos == 'above') - index = relatedNode.index; + index = relatedIndex; else if (dropPos == 'below') - index = relatedNode.index + 1; + index = relatedIndex + 1; - if (index != undefined) + if (index != undefined && index != -1) chrome.experimental.bookmarkManager.drop(parentId, index); else chrome.experimental.bookmarkManager.drop(parentId); @@ -1061,14 +1055,16 @@ for (var i = 0, command; command = commands[i]; i++) { function updateOpenCommands(e, command) { var selectedItem = e.target.selectedItem; var selectionCount; - if (e.target == tree) + if (e.target == tree) { selectionCount = selectedItem ? 1 : 0; - else + selectedItem = selectedItem.bookmarkNode; + } else { selectionCount = e.target.selectedItems.length; + } var isFolder = selectionCount == 1 && - selectedItem.bookmarkNode && - bmm.isFolder(selectedItem.bookmarkNode); + selectedItem && + bmm.isFolder(selectedItem); var multiple = selectionCount != 1 || isFolder; function hasBookmarks(node) { @@ -1095,11 +1091,11 @@ function updateOpenCommands(e, command) { 'open_all_incognito' : 'open_incognito'); break; } - e.canExecute = selectionCount > 0 && !!selectedItem.bookmarkNode; + e.canExecute = selectionCount > 0 && !!selectedItem; if (isFolder && e.canExecute) { // We need to get all the bookmark items in this tree. If the tree does not // contain any non-folders we need to disable the command. - var p = bmm.loadSubtree(selectedItem.bookmarkId); + var p = bmm.loadSubtree(selectedItem.id); p.addListener(function(node) { command.disabled = !node || !hasBookmarks(node); }); @@ -1154,12 +1150,12 @@ function canExcuteShared(e, isRecentOrSearch) { if (isRecentOrSearch) { e.canExecute = false; } else { - e.canExecute = list.items.length > 0; + e.canExecute = list.dataModel.length > 0; // The list might be loading so listen to the load event. var f = function() { list.removeEventListener('load', f); - command.disabled = list.items.length == 0; + command.disabled = list.dataModel.length == 0; }; list.addEventListener('load', f); } @@ -1206,7 +1202,7 @@ list.addEventListener('canExecute', function(e) { e.canExecute = false; command.hidden = true; } else { - var isFolder = bmm.isFolder(items[0].bookmarkNode); + var isFolder = bmm.isFolder(items[0]); e.canExecute = isFolder; command.hidden = !isFolder; } @@ -1219,7 +1215,7 @@ list.addEventListener('canExecute', function(e) { e.canExecute = false; command.hidden = false; } else { - var isFolder = bmm.isFolder(items[0].bookmarkNode); + var isFolder = bmm.isFolder(items[0]); e.canExecute = !isFolder; command.hidden = isFolder; } @@ -1345,8 +1341,19 @@ list.addEventListener('edit', function(e) { // New page context.parentId = bookmarkNode.parentId; chrome.bookmarks.create(context, function(node) { - list.remove(item); - list.selectedItem = bmm.listLookup[node.id]; + // A new node was created and will get added to the list due to the + // handler. + var dataModel = list.dataModel; + var index = dataModel.indexOf(bookmarkNode); + dataModel.splice(index, 1); + + // Select new item. + var newIndex = dataModel.findIndexById(node.id); + if (newIndex != -1) { + var sm = list.selectionModel; + list.scrollIndexIntoView(newIndex); + sm.leadIndex = sm.anchorIndex = sm.selectedIndex = newIndex; + } }); } else { // Edit @@ -1370,15 +1377,16 @@ list.addEventListener('canceledit', function(e) { * used for the show-in-folder command. */ function showInFolder() { - var bookmarkId = list.selectedItem.bookmarkNode.id; - var parentId = list.selectedItem.bookmarkNode.parentId; + var bookmarkNode = list.selectedItem; + var parentId = bookmarkNode.parentId; // After the list is loaded we should select the revealed item. - var f = function(e) { - var item = bmm.listLookup[bookmarkId]; - if (item) { - list.selectionModel.leadItem = item; - item.selected = true; + function f(e) { + var index; + if (bookmarkNode && (index = list.dataModel.indexOf(bookmarkNode))) { + var sm = list.selectionModel; + sm.anchorIndex = sm.leadIndex = sm.selectedIndex = index; + list.scrollIndexIntoView(index); } list.removeEventListener('load', f); } @@ -1407,9 +1415,7 @@ function getLinkController() { */ function getSelectedBookmarkNodes() { if (document.activeElement == list) { - return list.selectedItems.map(function(item) { - return item.bookmarkNode; - }); + return list.selectedItems; } else if (document.activeElement == tree) { return [tree.selectedItem.bookmarkNode]; } else { @@ -1505,9 +1511,18 @@ function newFolder() { // We need to do this in a timeout to be able to focus the newly created // item. setTimeout(function() { - var newItem = isTree ? bmm.treeLookup[newNode.id] : - bmm.listLookup[newNode.id]; - document.activeElement.selectedItem = newItem; + var newItem; + if (isTree) { + newItem = bmm.treeLookup[newNode.id]; + tree.selectedItem = newItem; + } else { + var index = list.dataModel.findIndexById(newNode.id); + var sm = list.selectionModel; + sm.anchorIndex = sm.leadIndex = sm.selectedIndex = index; + list.scrollIndexIntoView(index); + newItem = list.getListItemByIndex(index); + } + newItem.editing = true; }); }); @@ -1525,10 +1540,15 @@ function addPage() { parentId: parentId, id: 'new' }; - var newListItem = bmm.createListItem(fakeNode, false); - list.add(newListItem); - list.selectedItem = newListItem; - newListItem.editing = true; + + var dataModel = list.dataModel; + var length = dataModel.length; + dataModel.splice(length, 0, fakeNode); + var sm = list.selectionModel; + sm.anchorIndex = sm.leadIndex = sm.selectedIndex = length; + list.scrollIndexIntoView(length); + var li = list.getListItemByIndex(length); + li.editing = true; } /** @@ -1571,7 +1591,13 @@ function handleCommand(e) { break; case 'rename-folder-command': case 'edit-command': - document.activeElement.selectedItem.editing = true; + if (document.activeElement == list) { + var li = list.getListItem(list.selectedItem); + if (li) + li.editing = true; + } else { + document.activeElement.selectedItem.editing = true; + } break; case 'new-folder-command': newFolder(); diff --git a/chrome/browser/resources/shared/css/list.css b/chrome/browser/resources/shared/css/list.css index 5f91834..cbc3471 100644 --- a/chrome/browser/resources/shared/css/list.css +++ b/chrome/browser/resources/shared/css/list.css @@ -1,7 +1,10 @@ list { - overflow: auto; + display: block; outline: none; + overflow: auto; + position: relative; /* Make sure that item offsets are relative to the + list. */ } list > * { @@ -10,6 +13,7 @@ list > * { border: 1px solid rgba(255,255,255,0); /* transparent white */ border-radius: 2px; cursor: default; + display: block; line-height: 20px; margin: -1px 0; overflow: hidden; @@ -59,4 +63,3 @@ list > [selected]:hover { background-color: hsl(214, 91%, 87%); border-color: hsl(214, 91%, 65%); } - diff --git a/chrome/browser/resources/shared/js/cr/ui/array_data_model.js b/chrome/browser/resources/shared/js/cr/ui/array_data_model.js new file mode 100644 index 0000000..54837b4 --- /dev/null +++ b/chrome/browser/resources/shared/js/cr/ui/array_data_model.js @@ -0,0 +1,102 @@ +// Copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * @fileoverview This is a data model representin + */ + +cr.define('cr.ui', function() { + const EventTarget = cr.EventTarget; + const Event = cr.Event; + + /** + * A data model that wraps a simple array. + * @param {!Array} array The underlying array. + * @constructor + * @extends {EventTarget} + */ + function ArrayDataModel(array) { + this.array_ = array; + } + + ArrayDataModel.prototype = { + __proto__: EventTarget.prototype, + + /** + * The length of the data model. + * @type {number} + */ + get length() { + return this.array_.length; + }, + + /** + * Returns the item at the given index. + * @param {number} index The index of the element go get. + * @return {*} The element at the given index. + */ + item: function(index) { + return this.array_[index]; + }, + + /** + * Returns the first matching item. + * @param {*} item The item to find. + * @param {number=} opt_fromIndex If provided, then the searching start at + * the {@code opt_fromIndex}. + * @return {number} The index of the first found element or -1 if not found. + */ + indexOf: function(item, opt_fromIndex) { + return this.array_.indexOf(item, opt_fromIndex); + }, + + /** + * This removes and adds items to the model. + * + * This dispatches a splice event. + * + * @param {number} index The index of the item to update. + * @param {number} deleteCount The number of items to remove. + * @param {...*} The items to add. + * @return {!Array} An array with the removed items. + */ + splice: function(index, deleteCount, var_args) { + var arr = this.array_; + + // TODO(arv): Maybe unify splice and change events? + var e = new Event('splice'); + e.index = index; + e.removed = arr.slice(index, index + deleteCount); + e.added = Array.prototype.slice.call(arguments, 2); + + var rv = arr.splice.apply(arr, arguments); + + this.dispatchEvent(e); + + return rv; + }, + + /** + * Use this to update a given item in the array. This does not remove and + * reinsert a new item. + * + * This dispatches a change event. + * + * @param {number} index The index of the item to update. + */ + updateIndex: function(index) { + if (index < 0 || index >= this.length) + throw Error('Invalid index, ' + index); + + // TODO(arv): Maybe unify splice and change events? + var e = new Event('change'); + e.index = index; + this.dispatchEvent(e); + } + }; + + return { + ArrayDataModel: ArrayDataModel + }; +}); diff --git a/chrome/browser/resources/shared/js/cr/ui/list.js b/chrome/browser/resources/shared/js/cr/ui/list.js index f221279..f2412f5 100644 --- a/chrome/browser/resources/shared/js/cr/ui/list.js +++ b/chrome/browser/resources/shared/js/cr/ui/list.js @@ -10,6 +10,7 @@ cr.define('cr.ui', function() { const ListSelectionModel = cr.ui.ListSelectionModel; + const ArrayDataModel = cr.ui.ArrayDataModel; /** * Whether a mouse event is inside the element viewport. This will return @@ -29,6 +30,35 @@ cr.define('cr.ui', function() { } /** + * Creates an item (dataModel.item(0)) and measures its height. + * @param {!List} list The list to create the item for. + * @return {number} The height of the item, taking margins into account. + */ + function measureItem(list) { + var dataModel = list.dataModel; + if (!dataModel || !dataModel.length) + return 0; + var item = list.createItem(dataModel.item(0)); + list.appendChild(item); + var cs = item.ownerDocument.defaultView.getComputedStyle(item); + var mt = parseFloat(cs.marginTop); + var mb = parseFloat(cs.marginBottom); + var h = item.offsetHeight; + + // Handle margin collapsing. + if (mt < 0 && mb < 0) { + h += Math.min(mt, mb); + } else if (mt >= 0 && mb >= 0) { + h += Math.max(mt, mb); + } else { + h += mt + mb; + } + + list.removeChild(item); + return h; + } + + /** * Creates a new list element. * @param {Object=} opt_propertyBag Optional properties. * @constructor @@ -40,6 +70,58 @@ cr.define('cr.ui', function() { __proto__: HTMLUListElement.prototype, /** + * The height of list items. This is lazily calculated the first time it is + * needed. + * @type {number} + * @private + */ + itemHeight_: 0, + + dataModel_: null, + + /** + * The data model driving the list. + * @type {ListDataModel} + */ + set dataModel(dataModel) { + if (this.dataModel_ != dataModel) { + if (!this.boundHandleDataModelSplice_) { + this.boundHandleDataModelSplice_ = + cr.bind(this.handleDataModelSplice_, this); + this.boundHandleDataModelChange_ = + cr.bind(this.handleDataModelChange_, this); + } + + if (this.dataModel_) { + this.dataModel_.removeEventListener('splice', + this.boundHandleDataModelSplice_); + this.dataModel_.removeEventListener('change', + this.boundHandleDataModelChange_); + } + + this.dataModel_ = dataModel; + + this.cachedItems_ = {}; + this.selectionModel.clear(); + if (dataModel) + this.selectionModel.adjust(0, 0, dataModel.length); + + if (this.dataModel_) { + this.dataModel_.addEventListener('splice', + this.boundHandleDataModelSplice_); + this.dataModel_.addEventListener('change', + this.boundHandleDataModelChange_); + } + + this.redraw(); + } + }, + + get dataModel() { + return this.dataModel_; + }, + + /** * The selection model to use. * @type {cr.ui.ListSelectionModel} */ @@ -58,14 +140,15 @@ cr.define('cr.ui', function() { if (oldSm) { oldSm.removeEventListener('change', this.boundHandleOnChange_); - oldSm.removeEventListener('leadItemChange', this.boundHandleLeadChange_); + oldSm.removeEventListener('leadIndexChange', + this.boundHandleLeadChange_); } this.selectionModel_ = sm; if (sm) { sm.addEventListener('change', this.boundHandleOnChange_); - sm.addEventListener('leadItemChange', this.boundHandleLeadChange_); + sm.addEventListener('leadIndexChange', this.boundHandleLeadChange_); } }, @@ -74,10 +157,20 @@ cr.define('cr.ui', function() { * @type {cr.ui.ListItem} */ get selectedItem() { - return this.selectionModel.selectedItem; + var dataModel = this.dataModel; + if (dataModel) { + var index = this.selectionModel.selectedIndex; + if (index != -1) + return dataModel.item(index); + } + return null; }, set selectedItem(selectedItem) { - this.selectionModel.selectedItem = selectedItem; + var dataModel = this.dataModel; + if (dataModel) { + var index = this.dataModel.indexOf(selectedItem); + this.selectionModel.selectedIndex = index; + } }, /** @@ -85,7 +178,14 @@ cr.define('cr.ui', function() { * @type {!Array<cr.ui.ListItem>} */ get selectedItems() { - return this.selectionModel.selectedItems; + var indexes = this.selectionModel.selectedIndexes; + var dataModel = this.dataModel; + if (dataModel) { + return indexes.map(function(i) { + return dataModel.item(i); + }); + } + return []; }, /** @@ -100,78 +200,41 @@ cr.define('cr.ui', function() { batchCount_: 0, /** - * When adding a large collection of items to the list, the code should be - * wrapped in the startBatchAdd and startBatchEnd to increase performance. - * This hides the list while it is being built, and prevents it from - * incurring measurement performance hits in between each item. - * Be sure that the code will not return without calling finishBatchAdd - * or the list will not be shown. - * @private + * When making a lot of updates to the list, the code could be wrapped in + * the startBatchUpdates and finishBatchUpdates to increase performance. Be + * sure that the code will not return without calling endBatchUpdates or the + * list will not be correctly updated. */ - startBatchAdd: function() { - // If we're already in a batch, don't overwrite original display style. - if (this.batchCount_ == 0) { - this.originalDisplayStyle_ = this.style.display; - this.style.display = 'none'; - } + startBatchUpdates: function() { this.batchCount_++; }, /** - * See startBatchAdd. - * @private + * See startBatchUpdates. */ - finishBatchAdd: function() { + endBatchUpdates: function() { this.batchCount_--; - if (this.batchCount_ == 0) { - this.style.display = this.originalDisplayStyle_; - delete this.originalDisplayStyle; - } - }, - - add: function(listItem) { - this.appendChild(listItem); - - var uid = cr.getUid(listItem); - this.uidToListItem_[uid] = listItem; - - this.selectionModel.add(listItem); - }, - - addAt: function(listItem, index) { - this.insertBefore(listItem, this.items[index]); - - var uid = cr.getUid(listItem); - this.uidToListItem_[uid] = listItem; - - this.selectionModel.add(listItem); - }, - - remove: function(listItem) { - this.selectionModel.remove(listItem); - - this.removeChild(listItem); - - var uid = cr.getUid(listItem); - delete this.uidToListItem_[uid]; - }, - - clear: function() { - this.innerHTML = ''; - this.selectionModel.clear(); + if (this.batchCount_ == 0) + this.redraw(); }, /** * Initializes the element. */ decorate: function() { - this.uidToListItem_ = {}; + // Add fillers. + this.beforeFiller_ = this.ownerDocument.createElement('div'); + this.afterFiller_ = this.ownerDocument.createElement('div'); + this.appendChild(this.beforeFiller_); + this.appendChild(this.afterFiller_); - this.selectionModel = new ListSelectionModel(this); + var length = this.dataModel ? this.dataModel.length : 0; + this.selectionModel = new ListSelectionModel(length); this.addEventListener('mousedown', this.handleMouseDownUp_); this.addEventListener('mouseup', this.handleMouseDownUp_); this.addEventListener('keydown', this.handleKeyDown); + this.addEventListener('scroll', cr.bind(this.redraw, this)); // Make list focusable if (!this.hasAttribute('tabindex')) @@ -195,7 +258,13 @@ cr.define('cr.ui', function() { target = target.parentNode; } - this.selectionModel.handleMouseDownUp(e, target); + if (!target) { + this.selectionModel.handleMouseDownUp(e, -1); + } else { + var top = target.offsetTop; + var index = Math.floor(top / this.itemHeight_); + this.selectionModel.handleMouseDownUp(e, index); + } }, /** @@ -215,8 +284,9 @@ cr.define('cr.ui', function() { */ handleOnChange_: function(ce) { ce.changes.forEach(function(change) { - var listItem = this.uidToListItem_[change.uid]; - listItem.selected = change.selected; + var listItem = this.getListItemByIndex(change.index); + if (listItem) + listItem.selected = change.selected; }, this); cr.dispatchSimpleEvent(this, 'change'); @@ -228,23 +298,66 @@ cr.define('cr.ui', function() { * @private */ handleLeadChange_: function(pe) { - if (pe.oldValue) { - pe.oldValue.lead = false; + var element; + if (pe.oldValue != -1) { + if ((element = this.getListItemByIndex(pe.oldValue))) + element.lead = false; + } + + if (pe.newValue != -1) { + this.scrollIndexIntoView(pe.newValue); + if ((element = this.getListItemByIndex(pe.newValue))) + element.lead = true; } - if (pe.newValue) { - pe.newValue.lead = true; + }, + + handleDataModelSplice_: function(e) { + this.selectionModel.adjust(e.index, e.removed.length, e.added.length); + // Remove the cache of everything above index. + for (var index in this.cachedItems_) { + if (index >= e.index) + delete this.cachedItems_[index]; + } + this.redraw(); + }, + + handleDataModelChange_: function(e) { + if (e.index >= this.firstIndex_ && e.index < this.lastIndex_) { + delete this.cachedItems_; + this.redraw(); } }, /** - * Gets a unique ID for an item. This needs to be unique to the list but - * does not have to be gloabally unique. This uses {@code cr.getUid} by - * default. Override to provide a more efficient way to get the unique ID. - * @param {cr.ui.ListItem} item The item to get the unique ID for. - * @return + * Ensures that a given index is inside the viewport. + * @param {number} index The index of the item to scroll into view. + * @return {boolean} Whether any scrolling was needed. */ - itemToUid: function(item) { - return cr.getUid(item); + scrollIndexIntoView: function(index) { + var dataModel = this.dataModel; + if (!dataModel || index < 0 || index >= dataModel.length) + return false; + + var itemHeight = this.itemHeight_; + var scrollTop = this.scrollTop; + var top = index * itemHeight; + + if (top < scrollTop) { + this.scrollTop = top; + return true; + } else { + var clientHeight = this.clientHeight; + var cs = this.ownerDocument.defaultView.getComputedStyle(this); + var paddingY = parseInt(cs.paddingTop, 10) + + parseInt(cs.paddingBottom, 10); + + if (top + itemHeight > scrollTop + clientHeight - paddingY) { + this.scrollTop = top + itemHeight - clientHeight + paddingY; + return true; + } + } + + return false; }, /** @@ -253,9 +366,130 @@ cr.define('cr.ui', function() { getRectForContextMenu: function() { // TODO(arv): Add trait support so we can share more code between trees // and lists. - if (this.selectedItem) - return this.selectedItem.getBoundingClientRect(); + var index = this.selectionModel.selectedIndex; + var el = this.getListItemByIndex(index); + if (el) + return el.getBoundingClientRect(); return this.getBoundingClientRect(); + }, + + /** + * Takes a value from the data model and finds the associated list item. + * @param {*} value The value in the data model that we want to get the list + * item for. + * @return {ListItem} The first found list item or null if not found. + */ + getListItem: function(value) { + var dataModel = this.dataModel; + if (dataModel) { + var index = dataModel.indexOf(value); + return this.getListItemByIndex(index); + } + return null; + }, + + /** + * Find the list item element at the given index. + * @param {number} index The index of the list item to get. + * @return {ListItem} The found list item or null if not found. + */ + getListItemByIndex: function(index) { + if (index < this.firstIndex_ || index >= this.lastIndex_) + return null; + + return this.children[index - this.firstIndex_ + 1]; + }, + + /** + * Creates a new list item. + * @param {*} value The value to use for the item. + * @return {!ListItem} The newly created list item. + */ + createItem: function(value) { + return new cr.ui.ListItem({label: value}); + }, + + /** + * Redraws the viewport. + */ + redraw: function() { + if (this.batchCount_ != 0) + return; + + var dataModel = this.dataModel; + if (!dataModel) { + this.textContent = ''; + return; + } + + console.time('redraw'); + var scrollTop = this.scrollTop; + var clientHeight = this.clientHeight; + + if (!this.itemHeight_) { + this.itemHeight_ = measureItem(this); + } + + var itemHeight = this.itemHeight_; + + // We cache the list items since creating the DOM nodes is the most + // expensive part of redrawing. + var cachedItems = this.cachedItems_ || {}; + var newCachedItems = {}; + + var desiredScrollHeight = dataModel.length * itemHeight; + + var firstIndex = Math.floor(scrollTop / itemHeight); + var itemsInViewPort = Math.min(dataModel.length - firstIndex, + Math.ceil((scrollTop + clientHeight - firstIndex * itemHeight) / + itemHeight)); + var lastIndex = firstIndex + itemsInViewPort; + + this.textContent = ''; + + var oldFirstIndex = this.firstIndex_ || 0; + var oldLastIndex = this.lastIndex_ || 0; + + this.beforeFiller_.style.height = firstIndex * itemHeight + 'px'; + this.appendChild(this.beforeFiller_); + + var sm = this.selectionModel; + var leadIndex = sm.leadIndex; + + for (var y = firstIndex; y < lastIndex; y++) { + var dataItem = dataModel.item(y); + var listItem = cachedItems[y] || this.createItem(dataItem); + if (y == leadIndex) { + listItem.lead = true; + } + if (sm.getIndexSelected(y)) { + listItem.selected = true; + } + this.appendChild(listItem); + newCachedItems[y] = listItem; + } + + this.afterFiller_.style.height = + (dataModel.length - firstIndex - itemsInViewPort) * itemHeight + 'px'; + this.appendChild(this.afterFiller_); + + this.firstIndex_ = firstIndex; + this.lastIndex_ = lastIndex; + + this.cachedItems_ = newCachedItems; + + console.timeEnd('redraw'); + }, + + /** + * Redraws a single item + * @param {number} index The row index to redraw. + */ + redrawItem: function(index) { + if (index >= this.firstIndex_ && index < this.lastIndex_) { + delete this.cachedItems_[index]; + this.redraw(); + } } }; diff --git a/chrome/browser/resources/shared/js/cr/ui/list_item.js b/chrome/browser/resources/shared/js/cr/ui/list_item.js index 0cd8826..283216f 100644 --- a/chrome/browser/resources/shared/js/cr/ui/list_item.js +++ b/chrome/browser/resources/shared/js/cr/ui/list_item.js @@ -27,31 +27,26 @@ cr.define('cr.ui', function() { }, /** - * Whether the item is the lead in a selection. Setting this does not update - * the underlying selection model. This is only used for display purpose. - * @type {boolean} - */ - get lead() { - return this.hasAttribute('lead'); - }, - set lead(lead) { - if (lead) { - this.setAttribute('lead', ''); - this.scrollIntoViewIfNeeded(false); - } else { - this.removeAttribute('lead'); - } - }, - - /** * Called when an element is decorated as a list item. */ decorate: function() { } }; + /** + * Whether the item is selected. Setting this does not update the underlying + * selection model. This is only used for display purpose. + * @type {boolean} + */ cr.defineProperty(ListItem, 'selected', cr.PropertyKind.BOOL_ATTR); + /** + * Whether the item is the lead in a selection. Setting this does not update + * the underlying selection model. This is only used for display purpose. + * @type {boolean} + */ + cr.defineProperty(ListItem, 'lead', cr.PropertyKind.BOOL_ATTR); + return { ListItem: ListItem }; diff --git a/chrome/browser/resources/shared/js/cr/ui/list_selection_model.js b/chrome/browser/resources/shared/js/cr/ui/list_selection_model.js index ef9bce8..a95ac4c 100644 --- a/chrome/browser/resources/shared/js/cr/ui/list_selection_model.js +++ b/chrome/browser/resources/shared/js/cr/ui/list_selection_model.js @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// TODO(arv): Refactor parts of this into a SelectionController. + cr.define('cr.ui', function() { const Event = cr.Event; const EventTarget = cr.EventTarget; @@ -9,144 +11,162 @@ cr.define('cr.ui', function() { /** * Creates a new selection model that is to be used with lists. This is * implemented for vertical lists but changing the behavior for horizontal - * lists or icon views is a matter of overriding {@code getItemBefore}, - * {@code getItemAfter}, {@code getItemAbove} as well as {@code getItemBelow}. + * lists or icon views is a matter of overriding {@code getIndexBefore}, + * {@code getIndexAfter}, {@code getIndexAbove} as well as + * {@code getIndexBelow}. + * + * @param {number=} opt_length The number items in the selection. * * @constructor * @extends {!cr.EventTarget} */ - function ListSelectionModel(list) { - this.list = list; - this.selectedItems_ = {}; + function ListSelectionModel(opt_length) { + this.length_ = opt_length || 0; + // Even though selectedIndexes_ is really a map we use an array here to get + // iteration in the order of the indexes. + this.selectedIndexes_ = []; } ListSelectionModel.prototype = { __proto__: EventTarget.prototype, /** - * Returns the item below (y axis) the given element. - * @param {*} item The item to get the item below. - * @return {*} The item below or null if not found. + * The number of items in the model. + * @type {number} */ - getItemBelow: function(item) { - return item.nextElementSibling; + get length() { + return this.length_; }, /** - * Returns the item above (y axis) the given element. - * @param {*} item The item to get the item above. - * @return {*} The item below or null if not found. + * Returns the index below (y axis) the given element. + * @param {*} index The index to get the index below. + * @return {*} The index below or -1 if not found. */ - getItemAbove: function(item) { - return item.previousElementSibling; + getIndexBelow: function(index) { + if (index == this.getLastIndex()) + return -1; + return index + 1; }, /** - * Returns the item before (x axis) the given element. This returns null + * Returns the index above (y axis) the given element. + * @param {*} index The index to get the index above. + * @return {*} The index below or -1 if not found. + */ + getIndexAbove: function(index) { + return index - 1; + }, + + /** + * Returns the index before (x axis) the given element. This returns -1 * by default but override this for icon view and horizontal selection * models. * - * @param {*} item The item to get the item before. - * @return {*} The item before or null if not found. + * @param {*} index The index to get the index before. + * @return {*} The index before or -1 if not found. */ - getItemBefore: function(item) { - return null; + getIndexBefore: function(index) { + return -1; }, /** - * Returns the item after (x axis) the given element. This returns null + * Returns the index after (x axis) the given element. This returns -1 * by default but override this for icon view and horizontal selection * models. * - * @param {*} item The item to get the item after. - * @return {*} The item after or null if not found. + * @param {*} index The index to get the index after. + * @return {*} The index after or -1 if not found. */ - getItemAfter: function(item) { - return null; + getIndexAfter: function(index) { + return -1; }, /** - * Returns the next list item. This is the next logical and should not + * Returns the next list index. This is the next logical and should not * depend on any kind of layout of the list. - * @param {*} item The item to get the next item for. - * @return {*} The next item or null if not found. + * @param {*} index The index to get the next index for. + * @return {*} The next index or -1 if not found. */ - getNextItem: function(item) { - return item.nextElementSibling; + getNextIndex: function(index) { + if (index == this.getLastIndex()) + return -1; + return index + 1; }, /** - * Returns the prevous list item. This is the previous logical and should + * Returns the prevous list index. This is the previous logical and should * not depend on any kind of layout of the list. - * @param {*} item The item to get the previous item for. - * @return {*} The previous item or null if not found. + * @param {*} index The index to get the previous index for. + * @return {*} The previous index or -1 if not found. */ - getPreviousItem: function(item) { - return item.previousElementSibling; + getPreviousIndex: function(index) { + return index - 1; }, /** - * @return {*} The first item. + * @return {*} The first index. */ - getFirstItem: function() { - return this.list.firstElementChild; + getFirstIndex: function() { + return 0; }, /** - * @return {*} The last item. + * @return {*} The last index. */ - getLastItem: function() { - return this.list.lastElementChild; + getLastIndex: function() { + return this.length_ - 1; }, /** * Called by the view when the user does a mousedown or mouseup on the list. * @param {!Event} e The browser mousedown event. - * @param {*} item The item that was under the mouse pointer, null if none. + * @param {*} index The index that was under the mouse pointer, -1 if none. */ - handleMouseDownUp: function(e, item) { - var anchorItem = this.anchorItem; + handleMouseDownUp: function(e, index) { + var anchorIndex = this.anchorIndex; var isDown = e.type == 'mousedown'; this.beginChange_(); - if (!item) { + if (index == -1) { // On Mac we always clear the selection if the user clicks a blank area. // On Windows, we only clear the selection if neither Shift nor Ctrl are // pressed. if (cr.isMac) { this.clear(); } else if (!isDown && !e.shiftKey && !e.ctrlKey) - // Keep anchor and lead items. + // Keep anchor and lead indexes. Note that this is intentionally + // different than on the Mac. this.clearAllSelected_(); } else { if (cr.isMac ? e.metaKey : e.ctrlKey) { // Selection is handled at mouseUp on windows/linux, mouseDown on mac. if (cr.isMac? isDown : !isDown) { - // toggle the current one and make it anchor item - this.setItemSelected(item, !this.getItemSelected(item)); - this.leadItem = item; - this.anchorItem = item; + // toggle the current one and make it anchor index + this.setIndexSelected(index, !this.getIndexSelected(index)); + this.leadIndex = index; + this.anchorIndex = index; } - } else if (e.shiftKey && anchorItem && anchorItem != item) { + } else if (e.shiftKey && anchorIndex != -1 && anchorIndex != index) { // Shift is done in mousedown if (isDown) { this.clearAllSelected_(); - this.leadItem = item; - this.selectRange(anchorItem, item); + this.leadIndex = index; + this.selectRange(anchorIndex, index); } } else { // Right click for a context menu need to not clear the selection. var isRightClick = e.button == 2; - // If the item is selected this is handled in mouseup. - var itemSelected = this.getItemSelected(item); - if ((itemSelected && !isDown || !itemSelected && isDown) && - !(itemSelected && isRightClick)) { + // If the index is selected this is handled in mouseup. + var indexSelected = this.getIndexSelected(index); + if ((indexSelected && !isDown || !indexSelected && isDown) && + !(indexSelected && isRightClick)) { this.clearAllSelected_(); - this.setItemSelected(item, true); - this.leadItem = item; - this.anchorItem = item; + this.setIndexSelected(index, true); + this.leadIndex = index; + this.anchorIndex = index; } } } @@ -159,8 +179,8 @@ cr.define('cr.ui', function() { * @param {Event} e The keydown event. */ handleKeyDown: function(e) { - var newItem = null; - var leadItem = this.leadItem; + var newIndex = -1; + var leadIndex = this.leadIndex; var prevent = true; // Ctrl/Meta+A @@ -173,11 +193,11 @@ cr.define('cr.ui', function() { // Space if (e.keyCode == 32) { - if (leadItem != null) { - var selected = this.getItemSelected(leadItem); + if (leadIndex != -1) { + var selected = this.getIndexSelected(leadIndex); if (e.ctrlKey || !selected) { this.beginChange_(); - this.setItemSelected(leadItem, !selected); + this.setIndexSelected(leadIndex, !selected); this.endChange_(); return; } @@ -186,51 +206,51 @@ cr.define('cr.ui', function() { switch (e.keyIdentifier) { case 'Home': - newItem = this.getFirstItem(); + newIndex = this.getFirstIndex(); break; case 'End': - newItem = this.getLastItem(); + newIndex = this.getLastIndex(); break; case 'Up': - newItem = !leadItem ? - this.getLastItem() : this.getItemAbove(leadItem); + newIndex = leadIndex == -1 ? + this.getLastIndex() : this.getIndexAbove(leadIndex); break; case 'Down': - newItem = !leadItem ? - this.getFirstItem() : this.getItemBelow(leadItem); + newIndex = leadIndex == -1 ? + this.getFirstIndex() : this.getIndexBelow(leadIndex); break; case 'Left': - newItem = !leadItem ? - this.getLastItem() : this.getItemBefore(leadItem); + newIndex = leadIndex == -1 ? + this.getLastIndex() : this.getIndexBefore(leadIndex); break; case 'Right': - newItem = !leadItem ? - this.getFirstItem() : this.getItemAfter(leadItem); + newIndex = leadIndex == -1 ? + this.getFirstIndex() : this.getIndexAfter(leadIndex); break; default: prevent = false; } - if (newItem) { + if (newIndex != -1) { this.beginChange_(); - this.leadItem = newItem; + this.leadIndex = newIndex; if (e.shiftKey) { - var anchorItem = this.anchorItem; + var anchorIndex = this.anchorIndex; this.clearAllSelected_(); - if (!anchorItem) { - this.setItemSelected(newItem, true); - this.anchorItem = newItem; + if (anchorIndex == -1) { + this.setIndexSelected(newIndex, true); + this.anchorIndex = newIndex; } else { - this.selectRange(anchorItem, newItem); + this.selectRange(anchorIndex, newIndex); } } else if (e.ctrlKey && !cr.isMac) { - // Setting the lead item is done above + // Setting the lead index is done above // Mac does not allow you to change the lead. } else { this.clearAllSelected_(); - this.setItemSelected(newItem, true); - this.anchorItem = newItem; + this.setIndexSelected(newIndex, true); + this.anchorIndex = newIndex; } this.endChange_(); @@ -241,53 +261,55 @@ cr.define('cr.ui', function() { }, /** - * @type {!Array} The selected items. + * @type {!Array} The selected indexes. */ - get selectedItems() { - return Object.keys(this.selectedItems_).map(function(uid) { - return this.selectedItems_[uid]; - }, this); + get selectedIndexes() { + return Object.keys(this.selectedIndexes_).map(Number); }, - set selectedItems(selectedItems) { + set selectedIndexes(selectedIndexes) { this.beginChange_(); this.clearAllSelected_(); - for (var i = 0; i < selectedItems.length; i++) { - this.setItemSelected(selectedItems[i], true); + for (var i = 0; i < selectedIndexes.length; i++) { + this.setIndexSelected(selectedIndexes[i], true); + } + if (selectedIndexes.length) { + this.leadIndex = this.anchorIndex = selectedIndexes[0]; + } else { + this.leadIndex = this.anchorIndex = -1; } - this.leadItem = this.anchorItem = selectedItems[0] || null; this.endChange_(); }, /** - * Convenience getter which returns the first selected item. + * Convenience getter which returns the first selected index. * @type {*} */ - get selectedItem() { - for (var uid in this.selectedItems_) { - return this.selectedItems_[uid]; + get selectedIndex() { + for (var i in this.selectedIndexes_) { + return Number(i); } - return null; + return -1; }, - set selectedItem(selectedItem) { + set selectedIndex(selectedIndex) { this.beginChange_(); this.clearAllSelected_(); - if (selectedItem) { - this.selectedItems = [selectedItem]; + if (selectedIndex != -1) { + this.selectedIndexes = [selectedIndex]; } else { - this.leadItem = this.anchorItem = null; + this.leadIndex = this.anchorIndex = -1; } this.endChange_(); }, /** - * Selects a range of items, starting with {@code start} and ends with + * Selects a range of indexes, starting with {@code start} and ends with * {@code end}. - * @param {*} start The first item to select. - * @param {*} end The last item to select. + * @param {*} start The first index to select. + * @param {*} end The last index to select. */ selectRange: function(start, end) { // Swap if starts comes after end. - if (start.compareDocumentPosition(end) & Node.DOCUMENT_POSITION_PRECEDING) { + if (start > end) { var tmp = start; start = end; end = tmp; @@ -295,19 +317,19 @@ cr.define('cr.ui', function() { this.beginChange_(); - for (var item = start; item != end; item = this.getNextItem(item)) { - this.setItemSelected(item, true); + for (var index = start; index != end; index++) { + this.setIndexSelected(index, true); } - this.setItemSelected(end, true); + this.setIndexSelected(end, true); this.endChange_(); }, /** - * Selects all items. + * Selects all indexes. */ selectAll: function() { - this.selectRange(this.getFirstItem(), this.getLastItem()); + this.selectRange(this.getFirstIndex(), this.getLastIndex()); }, /** @@ -315,7 +337,8 @@ cr.define('cr.ui', function() { */ clear: function() { this.beginChange_(); - this.anchorItem = this.leadItem = null; + this.length_ = 0; + this.anchorIndex = this.leadIndex = -1; this.clearAllSelected_(); this.endChange_(); }, @@ -325,34 +348,33 @@ cr.define('cr.ui', function() { * @private */ clearAllSelected_: function() { - for (var uid in this.selectedItems_) { - this.setItemSelected(this.selectedItems_[uid], false); + for (var i in this.selectedIndexes_) { + this.setIndexSelected(i, false); } }, /** - * Sets the selecte state for an item. - * @param {*} item The item to set the selected state for. - * @param {boolean} b Whether to select the item or not. + * Sets the selected state for an index. + * @param {*} index The index to set the selected state for. + * @param {boolean} b Whether to select the index or not. */ - setItemSelected: function(item, b) { - var uid = this.list.itemToUid(item); - var oldSelected = uid in this.selectedItems_; + setIndexSelected: function(index, b) { + var oldSelected = index in this.selectedIndexes_; if (oldSelected == b) return; if (b) - this.selectedItems_[uid] = item; + this.selectedIndexes_[index] = true; else - delete this.selectedItems_[uid]; + delete this.selectedIndexes_[index]; this.beginChange_(); // Changing back? - if (uid in this.changedUids_ && this.changedUids_[uid] == !b) { - delete this.changedUids_[uid]; + if (index in this.changedIndexes_ && this.changedIndexes_[index] == !b) { + delete this.changedIndexes_[index]; } else { - this.changedUids_[uid] = b; + this.changedIndexes_[index] = b; } // End change dispatches an event which in turn may update the view. @@ -360,13 +382,12 @@ cr.define('cr.ui', function() { }, /** - * Whether a given item is selected or not. - * @param {*} item The item to check. - * @return {boolean} Whether an item is selected. + * Whether a given index is selected or not. + * @param {*} index The index to check. + * @return {boolean} Whether an index is selected. */ - getItemSelected: function(item) { - var uid = this.list.itemToUid(item); - return uid in this.selectedItems_; + getIndexSelected: function(index) { + return index in this.selectedIndexes_; }, /** @@ -377,7 +398,7 @@ cr.define('cr.ui', function() { beginChange_: function() { if (!this.changeCount_) { this.changeCount_ = 0; - this.changedUids_ = {}; + this.changedIndexes_ = {}; } this.changeCount_++; }, @@ -390,59 +411,95 @@ cr.define('cr.ui', function() { endChange_: function() { this.changeCount_--; if (!this.changeCount_) { - var uids = Object.keys(this.changedUids_); - if (uids.length) { + var indexes = Object.keys(this.changedIndexes_); + if (indexes.length) { var e = new Event('change'); - e.changes = uids.map(function(uid) { + e.changes = indexes.map(function(index) { return { - uid: uid, - selected: this.changedUids_[uid] + index: index, + selected: this.changedIndexes_[index] }; }, this); this.dispatchEvent(e); } - delete this.changedUids_; + delete this.changedIndexes_; delete this.changeCount_; } }, + leadIndex_: -1, + /** - * Called when an item is removed from the lisst. - * @param {cr.ui.ListItem} item The list item that was removed. + * The leadIndex is used with multiple selection and it is the index that + * the user is moving using the arrow keys. + * @type {*} */ - remove: function(item) { - if (item == this.leadItem) - this.leadItem = this.getNextItem(item) || this.getPreviousItem(item); - if (item == this.anchorItem) - this.anchorItem = this.getNextItem(item) || this.getPreviousItem(item); - - // Deselect when removing items. - if (this.getItemSelected(item)) - this.setItemSelected(item, false); + get leadIndex() { + return this.leadIndex_; + }, + set leadIndex(leadIndex) { + var li = Math.max(-1, Math.min(this.length_ - 1, leadIndex)); + if (li != this.leadIndex_) { + var oldLeadIndex = this.leadIndex_; + this.leadIndex_ = li; + cr.dispatchPropertyChange(this, 'leadIndex', li, oldLeadIndex); + } }, + anchorIndex_: -1, + /** - * Called when an item was added to the list. - * @param {cr.ui.ListItem} item The list item to add. + * The anchorIndex is used with multiple selection. + * @type {*} */ - add: function(item) { - // We could (should?) check if the item is selected here and update the - // selection model. - } - }; + get anchorIndex() { + return this.anchorIndex_; + }, + set anchorIndex(anchorIndex) { + var ai = Math.max(-1, Math.min(this.length_ - 1, anchorIndex)); + if (ai != this.anchorIndex_) { + var oldAnchorIndex = this.anchorIndex_; + this.anchorIndex_ = ai; + cr.dispatchPropertyChange(this, 'anchorIndex', ai, oldAnchorIndex); + } + }, - /** - * The anchorItem is used with multiple selection. - * @type {*} - */ - cr.defineProperty(ListSelectionModel, 'anchorItem', cr.PropertyKind.JS, null); + /** + * Adjust the selection by adding or removing a certain numbers of items. + * This should be called by the owner of the selection model as items are + * added and removed from the underlying data model. + * @param {number} index The index of the first change. + * @param {number} itemsRemoved Number of items removed. + * @param {number} itemsAdded Number of items added. + */ + adjust: function(index, itemsRemoved, itemsAdded) { + function getNewAdjustedIndex(i) { + if (i > index && i < index + itemsRemoved) { + return index + } else if (i >= index) { + return i + itemsAdded - itemsRemoved; + } + return i; + } - /** - * The leadItem is used with multiple selection and it is the item that the - * user is moving uysing the arrow keys. - * @type {*} - */ - cr.defineProperty(ListSelectionModel, 'leadItem', cr.PropertyKind.JS, null); + this.length_ += itemsAdded - itemsRemoved; + + var newMap = []; + for (var i in this.selectedIndexes_) { + if (i < index) { + newMap[i] = true; + } else if (i < index + itemsRemoved) { + // noop + } else { + newMap[Number(i) + itemsAdded - itemsRemoved] = true; + } + } + this.selectedIndexes_ = newMap; + + this.leadIndex = getNewAdjustedIndex(this.leadIndex); + this.anchorIndex = getNewAdjustedIndex(this.anchorIndex); + } + }; return { ListSelectionModel: ListSelectionModel diff --git a/chrome/browser/resources/shared/js/cr/ui/list_selection_model_test.html b/chrome/browser/resources/shared/js/cr/ui/list_selection_model_test.html new file mode 100644 index 0000000..9e77aa1 --- /dev/null +++ b/chrome/browser/resources/shared/js/cr/ui/list_selection_model_test.html @@ -0,0 +1,175 @@ +<!DOCTYPE html> +<html> +<head> +<title></title> +<style> + +</style> +<script src="http://closure-library.googlecode.com/svn/trunk/closure/goog/base.js"></script> +<script src="../../cr.js"></script> +<script src="../event_target.js"></script> +<script src="list_selection_model.js"></script> +<script> + +goog.require('goog.testing.jsunit'); + +</script> + +</head> +<body> + +<script> + +function range(start, end) { + var a = []; + for (var i = start; i <= end; i++) { + a.push(i); + } + return a; +} + +function createSelectionModel(len) { + return new cr.ui.ListSelectionModel(len); +} + +function testAdjust1() { + var sm = createSelectionModel(200); + + sm.leadIndex = sm.anchorIndex = sm.selectedIndex = 100; + sm.adjust(0, 10, 0); + + assertEquals(90, sm.leadIndex); + assertEquals(90, sm.anchorIndex); + assertEquals(90, sm.selectedIndex); +} + +function testAdjust2() { + var sm = createSelectionModel(200); + + sm.leadIndex = sm.anchorIndex = sm.selectedIndex = 50; + sm.adjust(60, 10, 0); + + assertEquals(50, sm.leadIndex); + assertEquals(50, sm.anchorIndex); + assertEquals(50, sm.selectedIndex); +} + +function testAdjust3() { + var sm = createSelectionModel(200); + + sm.leadIndex = sm.anchorIndex = sm.selectedIndex = 100; + sm.adjust(0, 0, 10); + + assertEquals(110, sm.leadIndex); + assertEquals(110, sm.anchorIndex); + assertEquals(110, sm.selectedIndex); +} + +function testAdjust4() { + var sm = createSelectionModel(200); + + sm.leadIndex = sm.anchorIndex = 100; + sm.selectRange(100, 110); + + sm.adjust(0, 10, 5); + + assertEquals(95, sm.leadIndex); + assertEquals(95, sm.anchorIndex); + assertArrayEquals(range(95, 105), sm.selectedIndexes); +} + +function testAdjust5() { + var sm = createSelectionModel(100); + + sm.leadIndex = sm.anchorIndex = sm.selectedIndex = 99; + + sm.adjust(99, 1, 0); + + assertEquals('lead', 98, sm.leadIndex); + assertEquals('anchor', 98, sm.anchorIndex); + assertArrayEquals([], sm.selectedIndexes); +} + +function testAdjust6() { + var sm = createSelectionModel(200); + + sm.leadIndex = sm.anchorIndex = 105; + sm.selectRange(100, 110); + + // Remove 100 - 105 + sm.adjust(100, 5, 0); + + assertEquals('lead', 100, sm.leadIndex); + assertEquals('anchor', 100, sm.anchorIndex); + assertArrayEquals(range(100, 105), sm.selectedIndexes); +} + +function testAdjust7() { + var sm = createSelectionModel(1); + + sm.leadIndex = sm.anchorIndex = sm.selectedIndex = 0; + + sm.adjust(0, 0, 10); + + assertEquals('lead', 10, sm.leadIndex); + assertEquals('anchor', 10, sm.anchorIndex); + assertArrayEquals([10], sm.selectedIndexes); +} + +function testAdjust8() { + var sm = createSelectionModel(100); + + sm.leadIndex = sm.anchorIndex = 50; + sm.selectAll(0, 99); + + sm.adjust(10, 80, 0); + + assertEquals('lead', 10, sm.leadIndex); + assertEquals('anchor', 10, sm.anchorIndex); + assertArrayEquals(range(0, 19), sm.selectedIndexes); +} + +function testAdjust9() { + var sm = createSelectionModel(10); + + sm.leadIndex = sm.anchorIndex = 5; + sm.selectAll(); + + // Remove all + sm.adjust(0, 10, 0); + + assertEquals('lead', -1, sm.leadIndex); + assertEquals('anchor', -1, sm.anchorIndex); + assertArrayEquals([], sm.selectedIndexes); +} + +function testAdjust10() { + var sm = createSelectionModel(10); + + sm.leadIndex = sm.anchorIndex = 5; + sm.selectAll(); + + sm.adjust(0, 10, 20); + + assertEquals('lead', 0, sm.leadIndex); + assertEquals('anchor', 0, sm.anchorIndex); + assertArrayEquals([], sm.selectedIndexes); +} + +function testAdjust11() { + var sm = createSelectionModel(20); + + sm.leadIndex = sm.anchorIndex = 10; + sm.selectAll(); + + sm.adjust(5, 20, 10); + + assertEquals('lead', 5, sm.leadIndex); + assertEquals('anchor', 5, sm.anchorIndex); + assertArrayEquals(range(0, 4), sm.selectedIndexes); +} + +</script> + +</body> +</html> diff --git a/chrome/chrome_browser.gypi b/chrome/chrome_browser.gypi index 1408310..1d602f7 100755 --- a/chrome/chrome_browser.gypi +++ b/chrome/chrome_browser.gypi @@ -3431,6 +3431,7 @@ { 'destination': '<(PRODUCT_DIR)/resources/shared/js/cr/ui', 'files': [ + 'browser/resources/shared/js/cr/ui/array_data_model.js', 'browser/resources/shared/js/cr/ui/command.js', 'browser/resources/shared/js/cr/ui/context_menu_handler.js', 'browser/resources/shared/js/cr/ui/list.js', |