1 // Copyright (c) 2012 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.
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.
8 cr.define('bmm', function() {
10 var ListItem = cr.ui.ListItem;
11 var ArrayDataModel = cr.ui.ArrayDataModel;
12 var ContextMenuButton = cr.ui.ContextMenuButton;
17 * Basic array data model for use with bookmarks.
18 * @param {!Array.<!BookmarkTreeNode>} items The bookmark items.
20 * @extends {ArrayDataModel}
22 function BookmarksArrayDataModel(items) {
23 ArrayDataModel.call(this, items);
26 BookmarksArrayDataModel.prototype = {
27 __proto__: ArrayDataModel.prototype,
30 * Finds the index of the bookmark with the given ID.
31 * @param {string} id The ID of the bookmark node to find.
32 * @return {number} The index of the found node or -1 if not found.
34 findIndexById: function(id) {
35 for (var i = 0; i < this.length; i++) {
36 if (this.item(i).id == id)
44 * Removes all children and appends a new child.
45 * @param {!Node} parent The node to remove all children from.
46 * @param {!Node} newChild The new child to append.
48 function replaceAllChildren(parent, newChild) {
50 while ((n = parent.lastChild)) {
51 parent.removeChild(n);
53 parent.appendChild(newChild);
57 * Creates a new bookmark list.
58 * @param {Object=} opt_propertyBag Optional properties.
60 * @extends {HTMLButtonElement}
62 var BookmarkList = cr.ui.define('list');
64 BookmarkList.prototype = {
65 __proto__: List.prototype,
68 decorate: function() {
69 List.prototype.decorate.call(this);
70 this.addEventListener('mousedown', this.handleMouseDown_);
72 // HACK(arv): http://crbug.com/40902
73 window.addEventListener('resize', this.redraw.bind(this));
75 // We could add the ContextMenuButton in the BookmarkListItem but it slows
76 // down redraws a lot so we do this on mouseovers instead.
77 this.addEventListener('mouseover', this.handleMouseOver_.bind(this));
82 createItem: function(bookmarkNode) {
83 return new BookmarkListItem(bookmarkNode);
86 /** @private {string} */
89 /** @private {number} */
93 * Reloads the list from the bookmarks backend.
96 var parentId = this.parentId;
98 var callback = this.handleBookmarkCallback_.bind(this);
104 else if (/^q=/.test(parentId))
105 chrome.bookmarks.search(parentId.slice(2), callback);
107 chrome.bookmarks.getChildren(parentId, callback);
111 * Callback function for loading items.
112 * @param {Array.<!BookmarkTreeNode>} items The loaded items.
115 handleBookmarkCallback_: function(items) {
121 // Failed to load bookmarks. Most likely due to the bookmark being
123 cr.dispatchSimpleEvent(this, 'invalidId');
127 this.dataModel = new BookmarksArrayDataModel(items);
130 cr.dispatchSimpleEvent(this, 'load');
134 * The bookmark node that the list is currently displaying. If we are
135 * currently displaying search this returns null.
136 * @type {BookmarkTreeNode}
141 var treeItem = bmm.treeLookup[this.parentId];
142 return treeItem && treeItem.bookmarkNode;
146 * @return {boolean} Whether we are currently showing search results.
148 isSearch: function() {
149 return this.parentId_[0] == 'q';
153 * @return {boolean} Whether we are editing an ephemeral item.
155 hasEphemeral: function() {
156 var dataModel = this.dataModel;
157 for (var i = 0; i < dataModel.array_.length; i++) {
158 if (dataModel.array_[i].id == 'new')
165 * Handles mouseover on the list so that we can add the context menu button
168 * @param {!Event} e The mouseover event object.
170 handleMouseOver_: function(e) {
172 while (el && el.parentNode != this) {
176 if (el && el.parentNode == this &&
178 !(el.lastChild instanceof ContextMenuButton)) {
179 el.appendChild(new ContextMenuButton);
184 * Dispatches an urlClicked event which is used to open URLs in new
187 * @param {string} url The URL that was clicked.
188 * @param {!Event} originalEvent The original click event object.
190 dispatchUrlClickedEvent_: function(url, originalEvent) {
191 var event = new Event('urlClicked', {bubbles: true});
193 event.originalEvent = originalEvent;
194 this.dispatchEvent(event);
198 * Handles mousedown events so that we can prevent the auto scroll as
201 * @param {!MouseEvent} e The mousedown event object.
203 handleMouseDown_: function(e) {
205 // WebKit no longer fires click events for middle clicks so we manually
206 // listen to mouse up to dispatch a click event.
207 this.addEventListener('mouseup', this.handleMiddleMouseUp_);
209 // When the user does a middle click we need to prevent the auto scroll
210 // in case the user is trying to middle click to open a bookmark in a
212 // We do not do this in case the target is an input since middle click
213 // is also paste on Linux and we don't want to break that.
214 if (e.target.tagName != 'INPUT')
220 * WebKit no longer dispatches click events for middle clicks so we need
223 * @param {!MouseEvent} e The mouse up event object.
225 handleMiddleMouseUp_: function(e) {
226 this.removeEventListener('mouseup', this.handleMiddleMouseUp_);
229 while (el.parentNode != this) {
232 var node = el.bookmarkNode;
233 if (node && !bmm.isFolder(node))
234 this.dispatchUrlClickedEvent_(node.url, e);
239 // Bookmark model update callbacks
240 handleBookmarkChanged: function(id, changeInfo) {
241 var dataModel = this.dataModel;
242 var index = dataModel.findIndexById(id);
244 var bookmarkNode = this.dataModel.item(index);
245 bookmarkNode.title = changeInfo.title;
246 if ('url' in changeInfo)
247 bookmarkNode.url = changeInfo['url'];
249 dataModel.updateIndex(index);
253 handleChildrenReordered: function(id, reorderInfo) {
254 if (this.parentId == id) {
255 // We create a new data model with updated items in the right order.
256 var dataModel = this.dataModel;
258 for (var i = this.dataModel.length - 1; i >= 0; i--) {
259 var bookmarkNode = dataModel.item(i);
260 items[bookmarkNode.id] = bookmarkNode;
263 for (var i = 0; i < reorderInfo.childIds.length; i++) {
264 newArray[i] = items[reorderInfo.childIds[i]];
265 newArray[i].index = i;
268 this.dataModel = new BookmarksArrayDataModel(newArray);
272 handleCreated: function(id, bookmarkNode) {
273 if (this.parentId == bookmarkNode.parentId)
274 this.dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
277 handleMoved: function(id, moveInfo) {
278 if (moveInfo.parentId == this.parentId ||
279 moveInfo.oldParentId == this.parentId) {
281 var dataModel = this.dataModel;
283 if (moveInfo.oldParentId == moveInfo.parentId) {
284 // Reorder within this folder
286 this.startBatchUpdates();
288 var bookmarkNode = this.dataModel.item(moveInfo.oldIndex);
289 this.dataModel.splice(moveInfo.oldIndex, 1);
290 this.dataModel.splice(moveInfo.index, 0, bookmarkNode);
292 this.endBatchUpdates();
294 if (moveInfo.oldParentId == this.parentId) {
295 // Move out of this folder
297 var index = dataModel.findIndexById(id);
299 dataModel.splice(index, 1);
302 if (moveInfo.parentId == this.parentId) {
303 // Move to this folder
305 chrome.bookmarks.get(id, function(bookmarkNodes) {
306 var bookmarkNode = bookmarkNodes[0];
307 dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
314 handleRemoved: function(id, removeInfo) {
315 var dataModel = this.dataModel;
316 var index = dataModel.findIndexById(id);
318 dataModel.splice(index, 1);
322 * Workaround for http://crbug.com/40902
325 fixWidth_: function() {
327 if (this.loadCount_ || !list)
330 // The width of the list is wrong after its content has changed.
331 // Fortunately the reported offsetWidth is correct so we can detect the
333 if (list.offsetWidth != list.parentNode.clientWidth - list.offsetLeft) {
334 // Set the width to the correct size. This causes the relayout.
335 list.style.width = list.parentNode.clientWidth - list.offsetLeft + 'px';
336 // Remove the temporary style.width in a timeout. Once the timer fires
337 // the size should not change since we already fixed the width.
338 window.setTimeout(function() {
339 list.style.width = '';
346 * The ID of the bookmark folder we are displaying.
349 cr.defineProperty(BookmarkList, 'parentId', cr.PropertyKind.JS,
355 * The contextMenu property.
358 cr.ui.contextMenuHandler.addContextMenuProperty(BookmarkList);
361 * Creates a new bookmark list item.
362 * @param {!BookmarkTreeNode} bookmarkNode The bookmark node this represents.
364 * @extends {cr.ui.ListItem}
366 function BookmarkListItem(bookmarkNode) {
367 var el = cr.doc.createElement('div');
368 el.bookmarkNode = bookmarkNode;
369 BookmarkListItem.decorate(el);
374 * Decorates an element as a bookmark list item.
375 * @param {!HTMLElement} el The element to decorate.
377 BookmarkListItem.decorate = function(el) {
378 el.__proto__ = BookmarkListItem.prototype;
382 BookmarkListItem.prototype = {
383 __proto__: ListItem.prototype,
386 decorate: function() {
387 ListItem.prototype.decorate.call(this);
389 var bookmarkNode = this.bookmarkNode;
391 this.draggable = true;
393 var labelEl = this.ownerDocument.createElement('div');
394 labelEl.className = 'label';
395 labelEl.textContent = bookmarkNode.title;
397 var urlEl = this.ownerDocument.createElement('div');
398 urlEl.className = 'url';
400 if (bmm.isFolder(bookmarkNode)) {
401 this.className = 'folder';
403 labelEl.style.backgroundImage = getFaviconImageSet(bookmarkNode.url);
404 labelEl.style.backgroundSize = '16px';
405 urlEl.textContent = bookmarkNode.url;
408 this.appendChild(labelEl);
409 this.appendChild(urlEl);
411 // Initially the ContextMenuButton was added here but it slowed down
412 // rendering a lot so it is now added using mouseover.
416 * The ID of the bookmark folder we are currently showing or loading.
420 return this.bookmarkNode.id;
424 * Whether the user is currently able to edit the list item.
428 return this.hasAttribute('editing');
430 set editing(editing) {
431 var oldEditing = this.editing;
432 if (oldEditing == editing)
435 var url = this.bookmarkNode.url;
436 var title = this.bookmarkNode.title;
437 var isFolder = bmm.isFolder(this.bookmarkNode);
439 var labelEl = this.firstChild;
440 var urlEl = labelEl.nextSibling;
441 var labelInput, urlInput;
443 // Handles enter and escape which trigger reset and commit respectively.
444 function handleKeydown(e) {
445 // Make sure that the tree does not handle the key.
448 // Calling list.focus blurs the input which will stop editing the list
450 switch (e.keyIdentifier) {
451 case 'U+001B': // Esc
452 labelInput.value = title;
454 urlInput.value = url;
456 cr.dispatchSimpleEvent(listItem, 'canceledit', true);
458 if (listItem.parentNode)
459 listItem.parentNode.focus();
461 case 'U+0009': // Tab
462 // urlInput is the last focusable element in the page. If we
463 // allowed Tab focus navigation and the page loses focus, we
464 // couldn't give focus on urlInput programatically. So, we prevent
465 // Tab focus navigation.
466 if (document.activeElement == urlInput && !e.ctrlKey &&
467 !e.metaKey && !e.shiftKey && !getValidURL(urlInput)) {
475 function getValidURL(input) {
476 var originalValue = input.value;
479 if (input.validity.valid)
480 return originalValue;
481 // Blink does not do URL fix up so we manually test if prepending
482 // 'http://' would make the URL valid.
483 // https://bugs.webkit.org/show_bug.cgi?id=29235
484 input.value = 'http://' + originalValue;
485 if (input.validity.valid)
488 input.value = originalValue;
492 function handleBlur(e) {
493 // When the blur event happens we do not know who is getting focus so we
494 // delay this a bit since we want to know if the other input got focus
495 // before deciding if we should exit edit mode.
496 var doc = e.target.ownerDocument;
497 window.setTimeout(function() {
498 var activeElement = doc.hasFocus() && doc.activeElement;
499 if (activeElement != urlInput && activeElement != labelInput) {
500 listItem.editing = false;
505 var doc = this.ownerDocument;
507 this.setAttribute('editing', '');
508 this.draggable = false;
510 labelInput = doc.createElement('input');
511 labelInput.placeholder =
512 loadTimeData.getString('name_input_placeholder');
513 replaceAllChildren(labelEl, labelInput);
514 labelInput.value = title;
517 urlInput = doc.createElement('input');
518 urlInput.type = 'url';
519 urlInput.required = true;
520 urlInput.placeholder =
521 loadTimeData.getString('url_input_placeholder');
523 // We also need a name for the input for the CSS to work.
524 urlInput.name = '-url-input-' + cr.createUid();
525 replaceAllChildren(urlEl, urlInput);
526 urlInput.value = url;
529 function stopPropagation(e) {
534 ['mousedown', 'mouseup', 'contextmenu', 'dblclick', 'paste'];
535 eventsToStop.forEach(function(type) {
536 labelInput.addEventListener(type, stopPropagation);
538 labelInput.addEventListener('keydown', handleKeydown);
539 labelInput.addEventListener('blur', handleBlur);
540 cr.ui.limitInputWidth(labelInput, this, 100, 0.5);
545 eventsToStop.forEach(function(type) {
546 urlInput.addEventListener(type, stopPropagation);
548 urlInput.addEventListener('keydown', handleKeydown);
549 urlInput.addEventListener('blur', handleBlur);
550 cr.ui.limitInputWidth(urlInput, this, 200, 0.5);
554 // Check that we have a valid URL and if not we do not change the
557 var urlInput = this.querySelector('.url input');
558 var newUrl = urlInput.value;
560 cr.dispatchSimpleEvent(this, 'canceledit', true);
564 newUrl = getValidURL(urlInput);
566 // In case the item was removed before getting here we should
568 if (listItem.parentNode) {
569 // Select the item again.
570 var dataModel = this.parentNode.dataModel;
571 var index = dataModel.indexOf(this.bookmarkNode);
572 var sm = this.parentNode.selectionModel;
573 sm.selectedIndex = sm.leadIndex = sm.anchorIndex = index;
575 alert(loadTimeData.getString('invalid_url'));
581 urlEl.textContent = this.bookmarkNode.url = newUrl;
584 this.removeAttribute('editing');
585 this.draggable = true;
587 labelInput = this.querySelector('.label input');
588 var newLabel = labelInput.value;
589 labelEl.textContent = this.bookmarkNode.title = newLabel;
592 if (newLabel != title) {
593 cr.dispatchSimpleEvent(this, 'rename', true);
595 } else if (newLabel != title || newUrl != url) {
596 cr.dispatchSimpleEvent(this, 'edit', true);
603 BookmarkList: BookmarkList,