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.
9 * @typedef {{childIds: Array.<string>}}
11 * @see chrome/common/extensions/api/bookmarks.json
16 * @typedef {{parentId: string,
18 * oldParentId: string,
21 * @see chrome/common/extensions/api/bookmarks.json
25 cr.define('bmm', function() {
26 var List = cr.ui.List;
27 var ListItem = cr.ui.ListItem;
28 var ArrayDataModel = cr.ui.ArrayDataModel;
29 var ContextMenuButton = cr.ui.ContextMenuButton;
32 * Basic array data model for use with bookmarks.
33 * @param {!Array.<!BookmarkTreeNode>} items The bookmark items.
35 * @extends {ArrayDataModel}
37 function BookmarksArrayDataModel(items) {
38 ArrayDataModel.call(this, items);
41 BookmarksArrayDataModel.prototype = {
42 __proto__: ArrayDataModel.prototype,
45 * Finds the index of the bookmark with the given ID.
46 * @param {string} id The ID of the bookmark node to find.
47 * @return {number} The index of the found node or -1 if not found.
49 findIndexById: function(id) {
50 for (var i = 0; i < this.length; i++) {
51 if (this.item(i).id == id)
59 * Removes all children and appends a new child.
60 * @param {!Node} parent The node to remove all children from.
61 * @param {!Node} newChild The new child to append.
63 function replaceAllChildren(parent, newChild) {
65 while ((n = parent.lastChild)) {
66 parent.removeChild(n);
68 parent.appendChild(newChild);
72 * Creates a new bookmark list.
73 * @param {Object=} opt_propertyBag Optional properties.
75 * @extends {cr.ui.List}
77 var BookmarkList = cr.ui.define('list');
79 BookmarkList.prototype = {
80 __proto__: List.prototype,
83 decorate: function() {
84 List.prototype.decorate.call(this);
85 this.addEventListener('mousedown', this.handleMouseDown_);
87 // HACK(arv): http://crbug.com/40902
88 window.addEventListener('resize', this.redraw.bind(this));
90 // We could add the ContextMenuButton in the BookmarkListItem but it slows
91 // down redraws a lot so we do this on mouseovers instead.
92 this.addEventListener('mouseover', this.handleMouseOver_.bind(this));
98 * @param {!BookmarkTreeNode} bookmarkNode
101 createItem: function(bookmarkNode) {
102 return new BookmarkListItem(bookmarkNode);
105 /** @private {string} */
108 /** @private {number} */
112 * Reloads the list from the bookmarks backend.
115 var parentId = this.parentId;
117 var callback = this.handleBookmarkCallback_.bind(this);
123 else if (/^q=/.test(parentId))
124 chrome.bookmarks.search(parentId.slice(2), callback);
126 chrome.bookmarks.getChildren(parentId, callback);
130 * Callback function for loading items.
131 * @param {Array.<!BookmarkTreeNode>} items The loaded items.
134 handleBookmarkCallback_: function(items) {
140 // Failed to load bookmarks. Most likely due to the bookmark being
142 cr.dispatchSimpleEvent(this, 'invalidId');
146 this.dataModel = new BookmarksArrayDataModel(items);
149 cr.dispatchSimpleEvent(this, 'load');
153 * The bookmark node that the list is currently displaying. If we are
154 * currently displaying search this returns null.
155 * @type {BookmarkTreeNode}
160 var treeItem = bmm.treeLookup[this.parentId];
161 return treeItem && treeItem.bookmarkNode;
165 * @return {boolean} Whether we are currently showing search results.
167 isSearch: function() {
168 return this.parentId_[0] == 'q';
172 * @return {boolean} Whether we are editing an ephemeral item.
174 hasEphemeral: function() {
175 var dataModel = this.dataModel;
176 for (var i = 0; i < dataModel.array_.length; i++) {
177 if (dataModel.array_[i].id == 'new')
184 * Handles mouseover on the list so that we can add the context menu button
187 * @param {!Event} e The mouseover event object.
189 handleMouseOver_: function(e) {
191 while (el && el.parentNode != this) {
195 if (el && el.parentNode == this &&
197 !(el.lastChild instanceof ContextMenuButton)) {
198 el.appendChild(new ContextMenuButton);
203 * Dispatches an urlClicked event which is used to open URLs in new
206 * @param {string} url The URL that was clicked.
207 * @param {!Event} originalEvent The original click event object.
209 dispatchUrlClickedEvent_: function(url, originalEvent) {
210 var event = new Event('urlClicked', {bubbles: true});
212 event.originalEvent = originalEvent;
213 this.dispatchEvent(event);
217 * Handles mousedown events so that we can prevent the auto scroll as
220 * @param {!Event} e The mousedown event object.
222 handleMouseDown_: function(e) {
223 e = /** @type {!MouseEvent} */(e);
225 // WebKit no longer fires click events for middle clicks so we manually
226 // listen to mouse up to dispatch a click event.
227 this.addEventListener('mouseup', this.handleMiddleMouseUp_);
229 // When the user does a middle click we need to prevent the auto scroll
230 // in case the user is trying to middle click to open a bookmark in a
232 // We do not do this in case the target is an input since middle click
233 // is also paste on Linux and we don't want to break that.
234 if (e.target.tagName != 'INPUT')
240 * WebKit no longer dispatches click events for middle clicks so we need
243 * @param {!Event} e The mouse up event object.
245 handleMiddleMouseUp_: function(e) {
246 e = /** @type {!MouseEvent} */(e);
247 this.removeEventListener('mouseup', this.handleMiddleMouseUp_);
250 while (el.parentNode != this) {
253 var node = el.bookmarkNode;
254 if (node && !bmm.isFolder(node))
255 this.dispatchUrlClickedEvent_(node.url, e);
260 // Bookmark model update callbacks
261 handleBookmarkChanged: function(id, changeInfo) {
262 var dataModel = this.dataModel;
263 var index = dataModel.findIndexById(id);
265 var bookmarkNode = this.dataModel.item(index);
266 bookmarkNode.title = changeInfo.title;
267 if ('url' in changeInfo)
268 bookmarkNode.url = changeInfo['url'];
270 dataModel.updateIndex(index);
276 * @param {ReorderInfo} reorderInfo
278 handleChildrenReordered: function(id, reorderInfo) {
279 if (this.parentId == id) {
280 // We create a new data model with updated items in the right order.
281 var dataModel = this.dataModel;
283 for (var i = this.dataModel.length - 1; i >= 0; i--) {
284 var bookmarkNode = dataModel.item(i);
285 items[bookmarkNode.id] = bookmarkNode;
288 for (var i = 0; i < reorderInfo.childIds.length; i++) {
289 newArray[i] = items[reorderInfo.childIds[i]];
290 newArray[i].index = i;
293 this.dataModel = new BookmarksArrayDataModel(newArray);
297 handleCreated: function(id, bookmarkNode) {
298 if (this.parentId == bookmarkNode.parentId)
299 this.dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
304 * @param {MoveInfo} moveInfo
306 handleMoved: function(id, moveInfo) {
307 if (moveInfo.parentId == this.parentId ||
308 moveInfo.oldParentId == this.parentId) {
310 var dataModel = this.dataModel;
312 if (moveInfo.oldParentId == moveInfo.parentId) {
313 // Reorder within this folder
315 this.startBatchUpdates();
317 var bookmarkNode = this.dataModel.item(moveInfo.oldIndex);
318 this.dataModel.splice(moveInfo.oldIndex, 1);
319 this.dataModel.splice(moveInfo.index, 0, bookmarkNode);
321 this.endBatchUpdates();
323 if (moveInfo.oldParentId == this.parentId) {
324 // Move out of this folder
326 var index = dataModel.findIndexById(id);
328 dataModel.splice(index, 1);
331 if (moveInfo.parentId == this.parentId) {
332 // Move to this folder
334 chrome.bookmarks.get(id, function(bookmarkNodes) {
335 var bookmarkNode = bookmarkNodes[0];
336 dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
343 handleRemoved: function(id, removeInfo) {
344 var dataModel = this.dataModel;
345 var index = dataModel.findIndexById(id);
347 dataModel.splice(index, 1);
351 * Workaround for http://crbug.com/40902
354 fixWidth_: function() {
356 if (this.loadCount_ || !list)
359 // The width of the list is wrong after its content has changed.
360 // Fortunately the reported offsetWidth is correct so we can detect the
362 if (list.offsetWidth != list.parentNode.clientWidth - list.offsetLeft) {
363 // Set the width to the correct size. This causes the relayout.
364 list.style.width = list.parentNode.clientWidth - list.offsetLeft + 'px';
365 // Remove the temporary style.width in a timeout. Once the timer fires
366 // the size should not change since we already fixed the width.
367 window.setTimeout(function() {
368 list.style.width = '';
375 * The ID of the bookmark folder we are displaying.
377 cr.defineProperty(BookmarkList, 'parentId', cr.PropertyKind.JS,
383 * The contextMenu property.
385 cr.ui.contextMenuHandler.addContextMenuProperty(BookmarkList);
386 /** @type {cr.ui.Menu} */
387 BookmarkList.prototype.contextMenu;
390 * Creates a new bookmark list item.
391 * @param {!BookmarkTreeNode} bookmarkNode The bookmark node this represents.
393 * @extends {cr.ui.ListItem}
395 function BookmarkListItem(bookmarkNode) {
396 var el = cr.doc.createElement('div');
397 el.bookmarkNode = bookmarkNode;
398 BookmarkListItem.decorate(el);
403 * Decorates an element as a bookmark list item.
404 * @param {!HTMLElement} el The element to decorate.
406 BookmarkListItem.decorate = function(el) {
407 el.__proto__ = BookmarkListItem.prototype;
411 BookmarkListItem.prototype = {
412 __proto__: ListItem.prototype,
415 decorate: function() {
416 ListItem.prototype.decorate.call(this);
418 var bookmarkNode = this.bookmarkNode;
420 this.draggable = true;
422 var labelEl = this.ownerDocument.createElement('div');
423 labelEl.className = 'label';
424 labelEl.textContent = bookmarkNode.title;
426 var urlEl = this.ownerDocument.createElement('div');
427 urlEl.className = 'url';
429 if (bmm.isFolder(bookmarkNode)) {
430 this.className = 'folder';
432 labelEl.style.backgroundImage = getFaviconImageSet(bookmarkNode.url);
433 labelEl.style.backgroundSize = '16px';
434 urlEl.textContent = bookmarkNode.url;
437 this.appendChild(labelEl);
438 this.appendChild(urlEl);
440 // Initially the ContextMenuButton was added here but it slowed down
441 // rendering a lot so it is now added using mouseover.
445 * The ID of the bookmark folder we are currently showing or loading.
449 return this.bookmarkNode.id;
453 * Whether the user is currently able to edit the list item.
457 return this.hasAttribute('editing');
459 set editing(editing) {
460 var oldEditing = this.editing;
461 if (oldEditing == editing)
464 var url = this.bookmarkNode.url;
465 var title = this.bookmarkNode.title;
466 var isFolder = bmm.isFolder(this.bookmarkNode);
468 var labelEl = this.firstChild;
469 var urlEl = labelEl.nextSibling;
470 var labelInput, urlInput;
472 // Handles enter and escape which trigger reset and commit respectively.
473 function handleKeydown(e) {
474 // Make sure that the tree does not handle the key.
477 // Calling list.focus blurs the input which will stop editing the list
479 switch (e.keyIdentifier) {
480 case 'U+001B': // Esc
481 labelInput.value = title;
483 urlInput.value = url;
485 cr.dispatchSimpleEvent(listItem, 'canceledit', true);
487 if (listItem.parentNode)
488 listItem.parentNode.focus();
490 case 'U+0009': // Tab
491 // urlInput is the last focusable element in the page. If we
492 // allowed Tab focus navigation and the page loses focus, we
493 // couldn't give focus on urlInput programatically. So, we prevent
494 // Tab focus navigation.
495 if (document.activeElement == urlInput && !e.ctrlKey &&
496 !e.metaKey && !e.shiftKey && !getValidURL(urlInput)) {
504 function getValidURL(input) {
505 var originalValue = input.value;
508 if (input.validity.valid)
509 return originalValue;
510 // Blink does not do URL fix up so we manually test if prepending
511 // 'http://' would make the URL valid.
512 // https://bugs.webkit.org/show_bug.cgi?id=29235
513 input.value = 'http://' + originalValue;
514 if (input.validity.valid)
517 input.value = originalValue;
521 function handleBlur(e) {
522 // When the blur event happens we do not know who is getting focus so we
523 // delay this a bit since we want to know if the other input got focus
524 // before deciding if we should exit edit mode.
525 var doc = e.target.ownerDocument;
526 window.setTimeout(function() {
527 var activeElement = doc.hasFocus() && doc.activeElement;
528 if (activeElement != urlInput && activeElement != labelInput) {
529 listItem.editing = false;
534 var doc = this.ownerDocument;
536 this.setAttribute('editing', '');
537 this.draggable = false;
539 labelInput = /** @type {HTMLElement} */(doc.createElement('input'));
540 labelInput.placeholder =
541 loadTimeData.getString('name_input_placeholder');
542 replaceAllChildren(labelEl, labelInput);
543 labelInput.value = title;
546 urlInput = /** @type {HTMLElement} */(doc.createElement('input'));
547 urlInput.type = 'url';
548 urlInput.required = true;
549 urlInput.placeholder =
550 loadTimeData.getString('url_input_placeholder');
552 // We also need a name for the input for the CSS to work.
553 urlInput.name = '-url-input-' + cr.createUid();
554 replaceAllChildren(assert(urlEl), urlInput);
555 urlInput.value = url;
558 var stopPropagation = function(e) {
563 ['mousedown', 'mouseup', 'contextmenu', 'dblclick', 'paste'];
564 eventsToStop.forEach(function(type) {
565 labelInput.addEventListener(type, stopPropagation);
567 labelInput.addEventListener('keydown', handleKeydown);
568 labelInput.addEventListener('blur', handleBlur);
569 cr.ui.limitInputWidth(labelInput, this, 100, 0.5);
574 eventsToStop.forEach(function(type) {
575 urlInput.addEventListener(type, stopPropagation);
577 urlInput.addEventListener('keydown', handleKeydown);
578 urlInput.addEventListener('blur', handleBlur);
579 cr.ui.limitInputWidth(urlInput, this, 200, 0.5);
583 // Check that we have a valid URL and if not we do not change the
586 var urlInput = this.querySelector('.url input');
587 var newUrl = urlInput.value;
589 cr.dispatchSimpleEvent(this, 'canceledit', true);
593 newUrl = getValidURL(urlInput);
595 // In case the item was removed before getting here we should
597 if (listItem.parentNode) {
598 // Select the item again.
599 var dataModel = this.parentNode.dataModel;
600 var index = dataModel.indexOf(this.bookmarkNode);
601 var sm = this.parentNode.selectionModel;
602 sm.selectedIndex = sm.leadIndex = sm.anchorIndex = index;
604 alert(loadTimeData.getString('invalid_url'));
610 urlEl.textContent = this.bookmarkNode.url = newUrl;
613 this.removeAttribute('editing');
614 this.draggable = true;
616 labelInput = this.querySelector('.label input');
617 var newLabel = labelInput.value;
618 labelEl.textContent = this.bookmarkNode.title = newLabel;
621 if (newLabel != title) {
622 cr.dispatchSimpleEvent(this, 'rename', true);
624 } else if (newLabel != title || newUrl != url) {
625 cr.dispatchSimpleEvent(this, 'edit', true);
632 BookmarkList: BookmarkList,
633 list: /** @type {Element} */(null), // Set when decorated.