1// Copyright (c) 2010 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5// TODO(arv): Now that this is driven by a data model, implement a data model 6// that handles the loading and the events from the bookmark backend. 7 8cr.define('bmm', function() { 9 const List = cr.ui.List; 10 const ListItem = cr.ui.ListItem; 11 const ArrayDataModel = cr.ui.ArrayDataModel; 12 const ContextMenuButton = cr.ui.ContextMenuButton; 13 14 /** 15 * Basic array data model for use with bookmarks. 16 * @param {!Array.<!BookmarkTreeNode>} items The bookmark items. 17 * @constructor 18 * @extends {ArrayDataModel} 19 */ 20 function BookmarksArrayDataModel(items) { 21 this.bookmarksArrayDataModelArray_ = items; 22 ArrayDataModel.call(this, items); 23 } 24 25 BookmarksArrayDataModel.prototype = { 26 __proto__: ArrayDataModel.prototype, 27 28 /** 29 * Finds the index of the bookmark with the given ID. 30 * @param {string} id The ID of the bookmark node to find. 31 * @return {number} The index of the found node or -1 if not found. 32 */ 33 findIndexById: function(id) { 34 var arr = this.bookmarksArrayDataModelArray_; 35 var length = arr.length 36 for (var i = 0; i < length; i++) { 37 if (arr[i].id == id) 38 return i; 39 } 40 return -1; 41 } 42 }; 43 44 /** 45 * Removes all children and appends a new child. 46 * @param {!Node} parent The node to remove all children from. 47 * @param {!Node} newChild The new child to append. 48 */ 49 function replaceAllChildren(parent, newChild) { 50 var n; 51 while ((n = parent.lastChild)) { 52 parent.removeChild(n); 53 } 54 parent.appendChild(newChild); 55 } 56 57 /** 58 * Creates a new bookmark list. 59 * @param {Object=} opt_propertyBag Optional properties. 60 * @constructor 61 * @extends {HTMLButtonElement} 62 */ 63 var BookmarkList = cr.ui.define('list'); 64 65 BookmarkList.prototype = { 66 __proto__: List.prototype, 67 68 /** @inheritDoc */ 69 decorate: function() { 70 List.prototype.decorate.call(this); 71 this.addEventListener('mousedown', this.handleMouseDown_); 72 73 // HACK(arv): http://crbug.com/40902 74 window.addEventListener('resize', this.redraw.bind(this)); 75 76 // We could add the ContextMenuButton in the BookmarkListItem but it slows 77 // down redraws a lot so we do this on mouseovers instead. 78 this.addEventListener('mouseover', this.handleMouseOver_.bind(this)); 79 }, 80 81 createItem: function(bookmarkNode) { 82 return new BookmarkListItem(bookmarkNode); 83 }, 84 85 parentId_: '', 86 87 /** 88 * Reloads the list from the bookmarks backend. 89 */ 90 reload: function() { 91 var parentId = this.parentId; 92 93 var callback = this.handleBookmarkCallback_.bind(this); 94 this.loading_ = true; 95 96 if (!parentId) { 97 callback([]); 98 } else if (/^q=/.test(parentId)) { 99 chrome.bookmarks.search(parentId.slice(2), callback); 100 } else if (parentId == 'recent') { 101 chrome.bookmarks.getRecent(50, callback); 102 } else { 103 chrome.bookmarks.getChildren(parentId, callback); 104 } 105 }, 106 107 /** 108 * Callback function for loading items. 109 * @param {Array.<!BookmarkTreeNode>} items The loaded items. 110 * @private 111 */ 112 handleBookmarkCallback_: function(items) { 113 if (!items) { 114 // Failed to load bookmarks. Most likely due to the bookmark being 115 // removed. 116 cr.dispatchSimpleEvent(this, 'invalidId'); 117 this.loading_ = false; 118 return; 119 } 120 121 this.dataModel = new BookmarksArrayDataModel(items); 122 123 this.loading_ = false; 124 this.fixWidth_(); 125 cr.dispatchSimpleEvent(this, 'load'); 126 }, 127 128 /** 129 * The bookmark node that the list is currently displaying. If we are 130 * currently displaying recent or search this returns null. 131 * @type {BookmarkTreeNode} 132 */ 133 get bookmarkNode() { 134 if (this.isSearch() || this.isRecent()) 135 return null; 136 var treeItem = bmm.treeLookup[this.parentId]; 137 return treeItem && treeItem.bookmarkNode; 138 }, 139 140 /** 141 * @return {boolean} Whether we are currently showing search results. 142 */ 143 isSearch: function() { 144 return this.parentId_[0] == 'q'; 145 }, 146 147 /** 148 * @return {boolean} Whether we are currently showing recent bookmakrs. 149 */ 150 isRecent: function() { 151 return this.parentId_ == 'recent'; 152 }, 153 154 /** 155 * Handles mouseover on the list so that we can add the context menu button 156 * lazily. 157 * @private 158 * @param {!Event} e The mouseover event object. 159 */ 160 handleMouseOver_: function(e) { 161 var el = e.target; 162 while (el && el.parentNode != this) { 163 el = el.parentNode; 164 } 165 166 if (el && el.parentNode == this && 167 !(el.lastChild instanceof ContextMenuButton)) { 168 el.appendChild(new ContextMenuButton); 169 } 170 }, 171 172 /** 173 * Dispatches an urlClicked event which is used to open URLs in new 174 * tabs etc. 175 * @private 176 * @param {string} url The URL that was clicked. 177 * @param {!Event} originalEvent The original click event object. 178 */ 179 dispatchUrlClickedEvent_: function(url, originalEvent) { 180 var event = new cr.Event('urlClicked', true, false); 181 event.url = url; 182 event.originalEvent = originalEvent; 183 this.dispatchEvent(event); 184 }, 185 186 /** 187 * Handles mousedown events so that we can prevent the auto scroll as 188 * necessary. 189 * @private 190 * @param {!MouseEvent} e The mousedown event object. 191 */ 192 handleMouseDown_: function(e) { 193 if (e.button == 1) { 194 // WebKit no longer fires click events for middle clicks so we manually 195 // listen to mouse up to dispatch a click event. 196 this.addEventListener('mouseup', this.handleMiddleMouseUp_); 197 198 // When the user does a middle click we need to prevent the auto scroll 199 // in case the user is trying to middle click to open a bookmark in a 200 // background tab. 201 // We do not do this in case the target is an input since middle click 202 // is also paste on Linux and we don't want to break that. 203 if (e.target.tagName != 'INPUT') 204 e.preventDefault(); 205 } 206 }, 207 208 /** 209 * WebKit no longer dispatches click events for middle clicks so we need 210 * to emulate it. 211 * @private 212 * @param {!MouseEvent} e The mouse up event object. 213 */ 214 handleMiddleMouseUp_: function(e) { 215 this.removeEventListener('mouseup', this.handleMiddleMouseUp_); 216 if (e.button == 1) { 217 var el = e.target; 218 while (el.parentNode != this) { 219 el = el.parentNode; 220 } 221 var node = el.bookmarkNode; 222 if (node && !bmm.isFolder(node)) 223 this.dispatchUrlClickedEvent_(node.url, e); 224 } 225 }, 226 227 // Bookmark model update callbacks 228 handleBookmarkChanged: function(id, changeInfo) { 229 var dataModel = this.dataModel; 230 var index = dataModel.findIndexById(id); 231 if (index != -1) { 232 var bookmarkNode = this.dataModel.item(index); 233 bookmarkNode.title = changeInfo.title; 234 if ('url' in changeInfo) 235 bookmarkNode.url = changeInfo['url']; 236 237 dataModel.updateIndex(index); 238 } 239 }, 240 241 handleChildrenReordered: function(id, reorderInfo) { 242 if (this.parentId == id) { 243 // We create a new data model with updated items in the right order. 244 var dataModel = this.dataModel; 245 var items = {}; 246 for (var i = this.dataModel.length -1 ; i >= 0; i--) { 247 var bookmarkNode = dataModel.item(i); 248 items[bookmarkNode.id] = bookmarkNode; 249 } 250 var newArray = []; 251 for (var i = 0; i < reorderInfo.childIds.length; i++) { 252 newArray[i] = items[reorderInfo.childIds[i]]; 253 } 254 255 this.dataModel = new BookmarksArrayDataModel(newArray); 256 } 257 }, 258 259 handleCreated: function(id, bookmarkNode) { 260 if (this.parentId == bookmarkNode.parentId) { 261 this.dataModel.splice(bookmarkNode.index, 0, bookmarkNode); 262 } 263 }, 264 265 handleMoved: function(id, moveInfo) { 266 if (moveInfo.parentId == this.parentId || 267 moveInfo.oldParentId == this.parentId) { 268 269 var dataModel = this.dataModel; 270 271 if (moveInfo.oldParentId == moveInfo.parentId) { 272 // Reorder within this folder 273 274 this.startBatchUpdates(); 275 276 var bookmarkNode = this.dataModel.item(moveInfo.oldIndex); 277 this.dataModel.splice(moveInfo.oldIndex, 1); 278 this.dataModel.splice(moveInfo.index, 0, bookmarkNode); 279 280 this.endBatchUpdates(); 281 } else { 282 if (moveInfo.oldParentId == this.parentId) { 283 // Move out of this folder 284 285 var index = dataModel.findIndexById(id); 286 if (index != -1) 287 dataModel.splice(index, 1); 288 } 289 290 if (moveInfo.parentId == list.parentId) { 291 // Move to this folder 292 var self = this; 293 chrome.bookmarks.get(id, function(bookmarkNodes) { 294 var bookmarkNode = bookmarkNodes[0]; 295 dataModel.splice(bookmarkNode.index, 0, bookmarkNode); 296 }); 297 } 298 } 299 } 300 }, 301 302 handleRemoved: function(id, removeInfo) { 303 var dataModel = this.dataModel; 304 var index = dataModel.findIndexById(id); 305 if (index != -1) 306 dataModel.splice(index, 1); 307 }, 308 309 /** 310 * Workaround for http://crbug.com/40902 311 * @private 312 */ 313 fixWidth_: function() { 314 if (this.loading_) 315 return; 316 317 // The width of the list is wrong after its content has changed. 318 // Fortunately the reported offsetWidth is correct so we can detect the 319 //incorrect width. 320 if (list.offsetWidth != list.parentNode.clientWidth - list.offsetLeft) { 321 // Set the width to the correct size. This causes the relayout. 322 list.style.width = list.parentNode.clientWidth - list.offsetLeft + 'px'; 323 // Remove the temporary style.width in a timeout. Once the timer fires 324 // the size should not change since we already fixed the width. 325 window.setTimeout(function() { 326 list.style.width = ''; 327 }, 0); 328 } 329 } 330 }; 331 332 /** 333 * The ID of the bookmark folder we are displaying. 334 * @type {string} 335 */ 336 cr.defineProperty(BookmarkList, 'parentId', cr.PropertyKind.JS, 337 function() { 338 this.reload(); 339 }); 340 341 /** 342 * The contextMenu property. 343 * @type {cr.ui.Menu} 344 */ 345 cr.ui.contextMenuHandler.addContextMenuProperty(BookmarkList); 346 347 /** 348 * Creates a new bookmark list item. 349 * @param {!BookmarkTreeNode} bookmarkNode The bookmark node this represents. 350 * @constructor 351 * @extends {cr.ui.ListItem} 352 */ 353 function BookmarkListItem(bookmarkNode) { 354 var el = cr.doc.createElement('div'); 355 el.bookmarkNode = bookmarkNode; 356 BookmarkListItem.decorate(el); 357 return el; 358 } 359 360 /** 361 * Decorates an element as a bookmark list item. 362 * @param {!HTMLElement} el The element to decorate. 363 */ 364 BookmarkListItem.decorate = function(el) { 365 el.__proto__ = BookmarkListItem.prototype; 366 el.decorate(); 367 }; 368 369 BookmarkListItem.prototype = { 370 __proto__: ListItem.prototype, 371 372 /** @inheritDoc */ 373 decorate: function() { 374 ListItem.prototype.decorate.call(this); 375 376 var bookmarkNode = this.bookmarkNode; 377 378 this.draggable = true; 379 380 var labelEl = this.ownerDocument.createElement('span'); 381 labelEl.className = 'label'; 382 labelEl.textContent = bookmarkNode.title; 383 384 var urlEl = this.ownerDocument.createElement('span'); 385 urlEl.className = 'url'; 386 urlEl.dir = 'ltr'; 387 388 if (bmm.isFolder(bookmarkNode)) { 389 this.className = 'folder'; 390 } else { 391 labelEl.style.backgroundImage = url('chrome://favicon/' + 392 bookmarkNode.url); 393 urlEl.textContent = bookmarkNode.url; 394 } 395 396 this.appendChild(labelEl); 397 this.appendChild(urlEl); 398 399 // Initially the ContextMenuButton was added here but it slowed down 400 // rendering a lot so it is now added using mouseover. 401 }, 402 403 /** 404 * The ID of the bookmark folder we are currently showing or loading. 405 * @type {string} 406 */ 407 get bookmarkId() { 408 return this.bookmarkNode.id; 409 }, 410 411 /** 412 * Whether the user is currently able to edit the list item. 413 * @type {boolean} 414 */ 415 get editing() { 416 return this.hasAttribute('editing'); 417 }, 418 set editing(editing) { 419 var oldEditing = this.editing; 420 if (oldEditing == editing) 421 return; 422 423 var url = this.bookmarkNode.url; 424 var title = this.bookmarkNode.title; 425 var isFolder = bmm.isFolder(this.bookmarkNode); 426 var listItem = this; 427 var labelEl = this.firstChild; 428 var urlEl = labelEl.nextSibling; 429 var labelInput, urlInput; 430 431 // Handles enter and escape which trigger reset and commit respectively. 432 function handleKeydown(e) { 433 // Make sure that the tree does not handle the key. 434 e.stopPropagation(); 435 436 // Calling list.focus blurs the input which will stop editing the list 437 // item. 438 switch (e.keyIdentifier) { 439 case 'U+001B': // Esc 440 labelInput.value = title; 441 if (!isFolder) 442 urlInput.value = url; 443 // fall through 444 cr.dispatchSimpleEvent(listItem, 'canceledit', true); 445 case 'Enter': 446 if (listItem.parentNode) 447 listItem.parentNode.focus(); 448 } 449 } 450 451 function handleBlur(e) { 452 // When the blur event happens we do not know who is getting focus so we 453 // delay this a bit since we want to know if the other input got focus 454 // before deciding if we should exit edit mode. 455 var doc = e.target.ownerDocument; 456 window.setTimeout(function() { 457 var activeElement = doc.activeElement; 458 if (activeElement != urlInput && activeElement != labelInput) { 459 listItem.editing = false; 460 } 461 }, 50); 462 } 463 464 var doc = this.ownerDocument; 465 if (editing) { 466 this.setAttribute('editing', ''); 467 this.draggable = false; 468 469 labelInput = doc.createElement('input'); 470 labelInput.placeholder = 471 localStrings.getString('name_input_placeholder'); 472 replaceAllChildren(labelEl, labelInput); 473 labelInput.value = title; 474 475 if (!isFolder) { 476 // To use :invalid we need to put the input inside a form 477 // https://bugs.webkit.org/show_bug.cgi?id=34733 478 var form = doc.createElement('form'); 479 urlInput = doc.createElement('input'); 480 urlInput.type = 'url'; 481 urlInput.required = true; 482 urlInput.placeholder = 483 localStrings.getString('url_input_placeholder'); 484 485 // We also need a name for the input for the CSS to work. 486 urlInput.name = '-url-input-' + cr.createUid(); 487 form.appendChild(urlInput); 488 replaceAllChildren(urlEl, form); 489 urlInput.value = url; 490 } 491 492 function stopPropagation(e) { 493 e.stopPropagation(); 494 } 495 496 var eventsToStop = 497 ['mousedown', 'mouseup', 'contextmenu', 'dblclick', 'paste']; 498 eventsToStop.forEach(function(type) { 499 labelInput.addEventListener(type, stopPropagation); 500 }); 501 labelInput.addEventListener('keydown', handleKeydown); 502 labelInput.addEventListener('blur', handleBlur); 503 cr.ui.limitInputWidth(labelInput, this, 20); 504 labelInput.focus(); 505 labelInput.select(); 506 507 if (!isFolder) { 508 eventsToStop.forEach(function(type) { 509 urlInput.addEventListener(type, stopPropagation); 510 }); 511 urlInput.addEventListener('keydown', handleKeydown); 512 urlInput.addEventListener('blur', handleBlur); 513 cr.ui.limitInputWidth(urlInput, this, 20); 514 } 515 516 } else { 517 // Check that we have a valid URL and if not we do not change the 518 // editing mode. 519 if (!isFolder) { 520 var urlInput = this.querySelector('.url input'); 521 var newUrl = urlInput.value; 522 if (!newUrl) { 523 cr.dispatchSimpleEvent(this, 'canceledit', true); 524 return; 525 } 526 527 if (!urlInput.validity.valid) { 528 // WebKit does not do URL fix up so we manually test if prepending 529 // 'http://' would make the URL valid. 530 // https://bugs.webkit.org/show_bug.cgi?id=29235 531 urlInput.value = 'http://' + newUrl; 532 if (!urlInput.validity.valid) { 533 // still invalid 534 urlInput.value = newUrl; 535 536 // In case the item was removed before getting here we should 537 // not alert. 538 if (listItem.parentNode) { 539 // Select the item again. 540 var dataModel = this.parentNode.dataModel; 541 var index = dataModel.indexOf(this.bookmarkNode); 542 var sm = this.parentNode.selectionModel; 543 sm.selectedIndex = sm.leadIndex = sm.anchorIndex = index; 544 545 alert(localStrings.getString('invalid_url')); 546 } 547 urlInput.focus(); 548 urlInput.select(); 549 return; 550 } 551 newUrl = 'http://' + newUrl; 552 } 553 urlEl.textContent = this.bookmarkNode.url = newUrl; 554 } 555 556 this.removeAttribute('editing'); 557 this.draggable = true; 558 559 labelInput = this.querySelector('.label input'); 560 var newLabel = labelInput.value; 561 labelEl.textContent = this.bookmarkNode.title = newLabel; 562 563 if (isFolder) { 564 if (newLabel != title) { 565 cr.dispatchSimpleEvent(this, 'rename', true); 566 } 567 } else if (newLabel != title || newUrl != url) { 568 cr.dispatchSimpleEvent(this, 'edit', true); 569 } 570 } 571 } 572 }; 573 574 return { 575 BookmarkList: BookmarkList 576 }; 577}); 578