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() {
28 var List
= cr
.ui
.List
;
29 var ListItem
= cr
.ui
.ListItem
;
30 var ArrayDataModel
= cr
.ui
.ArrayDataModel
;
31 var ContextMenuButton
= cr
.ui
.ContextMenuButton
;
34 * Basic array data model for use with bookmarks.
35 * @param {!Array<!BookmarkTreeNode>} items The bookmark items.
37 * @extends {ArrayDataModel}
39 function BookmarksArrayDataModel(items
) {
40 ArrayDataModel
.call(this, items
);
43 BookmarksArrayDataModel
.prototype = {
44 __proto__
: ArrayDataModel
.prototype,
47 * Finds the index of the bookmark with the given ID.
48 * @param {string} id The ID of the bookmark node to find.
49 * @return {number} The index of the found node or -1 if not found.
51 findIndexById: function(id
) {
52 for (var i
= 0; i
< this.length
; i
++) {
53 if (this.item(i
).id
== id
)
61 * Removes all children and appends a new child.
62 * @param {!Node} parent The node to remove all children from.
63 * @param {!Node} newChild The new child to append.
65 function replaceAllChildren(parent
, newChild
) {
67 while ((n
= parent
.lastChild
)) {
68 parent
.removeChild(n
);
70 parent
.appendChild(newChild
);
74 * Creates a new bookmark list.
75 * @param {Object=} opt_propertyBag Optional properties.
77 * @extends {cr.ui.List}
79 var BookmarkList
= cr
.ui
.define('list');
81 BookmarkList
.prototype = {
82 __proto__
: List
.prototype,
85 decorate: function() {
86 List
.prototype.decorate
.call(this);
87 this.addEventListener('mousedown', this.handleMouseDown_
);
89 // HACK(arv): http://crbug.com/40902
90 window
.addEventListener('resize', this.redraw
.bind(this));
92 // We could add the ContextMenuButton in the BookmarkListItem but it slows
93 // down redraws a lot so we do this on mouseovers instead.
94 this.addEventListener('mouseover', this.handleMouseOver_
.bind(this));
100 * @param {!BookmarkTreeNode} bookmarkNode
103 createItem: function(bookmarkNode
) {
104 return new BookmarkListItem(bookmarkNode
);
107 /** @private {string} */
110 /** @private {number} */
114 * Reloads the list from the bookmarks backend.
117 var parentId
= this.parentId
;
119 var callback
= this.handleBookmarkCallback_
.bind(this);
125 else if (/^q=/.test(parentId
))
126 chrome
.bookmarks
.search(parentId
.slice(2), callback
);
128 chrome
.bookmarks
.getChildren(parentId
, callback
);
132 * Callback function for loading items.
133 * @param {Array<!BookmarkTreeNode>} items The loaded items.
136 handleBookmarkCallback_: function(items
) {
142 // Failed to load bookmarks. Most likely due to the bookmark being
144 cr
.dispatchSimpleEvent(this, 'invalidId');
148 this.dataModel
= new BookmarksArrayDataModel(items
);
151 cr
.dispatchSimpleEvent(this, 'load');
155 * The bookmark node that the list is currently displaying. If we are
156 * currently displaying search this returns null.
157 * @type {BookmarkTreeNode}
162 var treeItem
= bmm
.treeLookup
[this.parentId
];
163 return treeItem
&& treeItem
.bookmarkNode
;
167 * @return {boolean} Whether we are currently showing search results.
169 isSearch: function() {
170 return this.parentId_
[0] == 'q';
174 * @return {boolean} Whether we are editing an ephemeral item.
176 hasEphemeral: function() {
177 var dataModel
= this.dataModel
;
178 for (var i
= 0; i
< dataModel
.array_
.length
; i
++) {
179 if (dataModel
.array_
[i
].id
== 'new')
186 * Handles mouseover on the list so that we can add the context menu button
189 * @param {!Event} e The mouseover event object.
191 handleMouseOver_: function(e
) {
193 while (el
&& el
.parentNode
!= this) {
197 if (el
&& el
.parentNode
== this &&
199 !(el
.lastChild
instanceof ContextMenuButton
)) {
200 el
.appendChild(new ContextMenuButton
);
205 * Dispatches an urlClicked event which is used to open URLs in new
208 * @param {string} url The URL that was clicked.
209 * @param {!Event} originalEvent The original click event object.
211 dispatchUrlClickedEvent_: function(url
, originalEvent
) {
212 var event
= new Event('urlClicked', {bubbles
: true});
214 event
.originalEvent
= originalEvent
;
215 this.dispatchEvent(event
);
219 * Handles mousedown events so that we can prevent the auto scroll as
222 * @param {!Event} e The mousedown event object.
224 handleMouseDown_: function(e
) {
225 e
= /** @type {!MouseEvent} */(e
);
227 // WebKit no longer fires click events for middle clicks so we manually
228 // listen to mouse up to dispatch a click event.
229 this.addEventListener('mouseup', this.handleMiddleMouseUp_
);
231 // When the user does a middle click we need to prevent the auto scroll
232 // in case the user is trying to middle click to open a bookmark in a
234 // We do not do this in case the target is an input since middle click
235 // is also paste on Linux and we don't want to break that.
236 if (e
.target
.tagName
!= 'INPUT')
242 * WebKit no longer dispatches click events for middle clicks so we need
245 * @param {!Event} e The mouse up event object.
247 handleMiddleMouseUp_: function(e
) {
248 e
= /** @type {!MouseEvent} */(e
);
249 this.removeEventListener('mouseup', this.handleMiddleMouseUp_
);
252 while (el
.parentNode
!= this) {
255 var node
= el
.bookmarkNode
;
256 if (node
&& !bmm
.isFolder(node
))
257 this.dispatchUrlClickedEvent_(node
.url
, e
);
262 // Bookmark model update callbacks
263 handleBookmarkChanged: function(id
, changeInfo
) {
264 var dataModel
= this.dataModel
;
265 var index
= dataModel
.findIndexById(id
);
267 var bookmarkNode
= this.dataModel
.item(index
);
268 bookmarkNode
.title
= changeInfo
.title
;
269 if ('url' in changeInfo
)
270 bookmarkNode
.url
= changeInfo
['url'];
272 dataModel
.updateIndex(index
);
278 * @param {ReorderInfo} reorderInfo
280 handleChildrenReordered: function(id
, reorderInfo
) {
281 if (this.parentId
== id
) {
282 // We create a new data model with updated items in the right order.
283 var dataModel
= this.dataModel
;
285 for (var i
= this.dataModel
.length
- 1; i
>= 0; i
--) {
286 var bookmarkNode
= dataModel
.item(i
);
287 items
[bookmarkNode
.id
] = bookmarkNode
;
290 for (var i
= 0; i
< reorderInfo
.childIds
.length
; i
++) {
291 newArray
[i
] = items
[reorderInfo
.childIds
[i
]];
292 newArray
[i
].index
= i
;
295 this.dataModel
= new BookmarksArrayDataModel(newArray
);
299 handleCreated: function(id
, bookmarkNode
) {
300 if (this.parentId
== bookmarkNode
.parentId
)
301 this.dataModel
.splice(bookmarkNode
.index
, 0, bookmarkNode
);
306 * @param {MoveInfo} moveInfo
308 handleMoved: function(id
, moveInfo
) {
309 if (moveInfo
.parentId
== this.parentId
||
310 moveInfo
.oldParentId
== this.parentId
) {
312 var dataModel
= this.dataModel
;
314 if (moveInfo
.oldParentId
== moveInfo
.parentId
) {
315 // Reorder within this folder
317 this.startBatchUpdates();
319 var bookmarkNode
= this.dataModel
.item(moveInfo
.oldIndex
);
320 this.dataModel
.splice(moveInfo
.oldIndex
, 1);
321 this.dataModel
.splice(moveInfo
.index
, 0, bookmarkNode
);
323 this.endBatchUpdates();
325 if (moveInfo
.oldParentId
== this.parentId
) {
326 // Move out of this folder
328 var index
= dataModel
.findIndexById(id
);
330 dataModel
.splice(index
, 1);
333 if (moveInfo
.parentId
== this.parentId
) {
334 // Move to this folder
336 chrome
.bookmarks
.get(id
, function(bookmarkNodes
) {
337 var bookmarkNode
= bookmarkNodes
[0];
338 dataModel
.splice(bookmarkNode
.index
, 0, bookmarkNode
);
345 handleRemoved: function(id
, removeInfo
) {
346 var dataModel
= this.dataModel
;
347 var index
= dataModel
.findIndexById(id
);
349 dataModel
.splice(index
, 1);
353 * Workaround for http://crbug.com/40902
356 fixWidth_: function() {
358 if (this.loadCount_
|| !list
)
361 // The width of the list is wrong after its content has changed.
362 // Fortunately the reported offsetWidth is correct so we can detect the
364 if (list
.offsetWidth
!= list
.parentNode
.clientWidth
- list
.offsetLeft
) {
365 // Set the width to the correct size. This causes the relayout.
366 list
.style
.width
= list
.parentNode
.clientWidth
- list
.offsetLeft
+ 'px';
367 // Remove the temporary style.width in a timeout. Once the timer fires
368 // the size should not change since we already fixed the width.
369 window
.setTimeout(function() {
370 list
.style
.width
= '';
377 * The ID of the bookmark folder we are displaying.
379 cr
.defineProperty(BookmarkList
, 'parentId', cr
.PropertyKind
.JS
,
385 * The contextMenu property.
387 cr
.ui
.contextMenuHandler
.addContextMenuProperty(BookmarkList
);
388 /** @type {cr.ui.Menu} */
389 BookmarkList
.prototype.contextMenu
;
392 * Creates a new bookmark list item.
393 * @param {!BookmarkTreeNode} bookmarkNode The bookmark node this represents.
395 * @extends {cr.ui.ListItem}
397 function BookmarkListItem(bookmarkNode
) {
398 var el
= cr
.doc
.createElement('div');
399 el
.bookmarkNode
= bookmarkNode
;
400 BookmarkListItem
.decorate(el
);
405 * Decorates an element as a bookmark list item.
406 * @param {!HTMLElement} el The element to decorate.
408 BookmarkListItem
.decorate = function(el
) {
409 el
.__proto__
= BookmarkListItem
.prototype;
413 BookmarkListItem
.prototype = {
414 __proto__
: ListItem
.prototype,
417 decorate: function() {
418 ListItem
.prototype.decorate
.call(this);
420 var bookmarkNode
= this.bookmarkNode
;
422 this.draggable
= true;
424 var labelEl
= this.ownerDocument
.createElement('div');
425 labelEl
.className
= 'label';
426 labelEl
.textContent
= bookmarkNode
.title
;
428 var urlEl
= this.ownerDocument
.createElement('div');
429 urlEl
.className
= 'url';
431 if (bmm
.isFolder(bookmarkNode
)) {
432 this.className
= 'folder';
434 labelEl
.style
.backgroundImage
= getFaviconImageSet(bookmarkNode
.url
);
435 labelEl
.style
.backgroundSize
= '16px';
436 urlEl
.textContent
= bookmarkNode
.url
;
439 this.appendChild(labelEl
);
440 this.appendChild(urlEl
);
442 // Initially the ContextMenuButton was added here but it slowed down
443 // rendering a lot so it is now added using mouseover.
447 * The ID of the bookmark folder we are currently showing or loading.
451 return this.bookmarkNode
.id
;
455 * Whether the user is currently able to edit the list item.
459 return this.hasAttribute('editing');
461 set editing(editing
) {
462 var oldEditing
= this.editing
;
463 if (oldEditing
== editing
)
466 var url
= this.bookmarkNode
.url
;
467 var title
= this.bookmarkNode
.title
;
468 var isFolder
= bmm
.isFolder(this.bookmarkNode
);
470 var labelEl
= this.firstChild
;
471 var urlEl
= labelEl
.nextSibling
;
472 var labelInput
, urlInput
;
474 // Handles enter and escape which trigger reset and commit respectively.
475 function handleKeydown(e
) {
476 // Make sure that the tree does not handle the key.
479 // Calling list.focus blurs the input which will stop editing the list
481 switch (e
.keyIdentifier
) {
482 case 'U+001B': // Esc
483 labelInput
.value
= title
;
485 urlInput
.value
= url
;
487 cr
.dispatchSimpleEvent(listItem
, 'canceledit', true);
489 if (listItem
.parentNode
)
490 listItem
.parentNode
.focus();
492 case 'U+0009': // Tab
493 // urlInput is the last focusable element in the page. If we
494 // allowed Tab focus navigation and the page loses focus, we
495 // couldn't give focus on urlInput programatically. So, we prevent
496 // Tab focus navigation.
497 if (document
.activeElement
== urlInput
&& !e
.ctrlKey
&&
498 !e
.metaKey
&& !e
.shiftKey
&& !getValidURL(urlInput
)) {
506 function getValidURL(input
) {
507 var originalValue
= input
.value
;
510 if (input
.validity
.valid
)
511 return originalValue
;
512 // Blink does not do URL fix up so we manually test if prepending
513 // 'http://' would make the URL valid.
514 // https://bugs.webkit.org/show_bug.cgi?id=29235
515 input
.value
= 'http://' + originalValue
;
516 if (input
.validity
.valid
)
519 input
.value
= originalValue
;
523 function handleBlur(e
) {
524 // When the blur event happens we do not know who is getting focus so we
525 // delay this a bit since we want to know if the other input got focus
526 // before deciding if we should exit edit mode.
527 var doc
= e
.target
.ownerDocument
;
528 window
.setTimeout(function() {
529 var activeElement
= doc
.hasFocus() && doc
.activeElement
;
530 if (activeElement
!= urlInput
&& activeElement
!= labelInput
) {
531 listItem
.editing
= false;
536 var doc
= this.ownerDocument
;
538 this.setAttribute('editing', '');
539 this.draggable
= false;
541 labelInput
= /** @type {HTMLElement} */(doc
.createElement('input'));
542 labelInput
.placeholder
=
543 loadTimeData
.getString('name_input_placeholder');
544 replaceAllChildren(labelEl
, labelInput
);
545 labelInput
.value
= title
;
548 urlInput
= /** @type {HTMLElement} */(doc
.createElement('input'));
549 urlInput
.type
= 'url';
550 urlInput
.required
= true;
551 urlInput
.placeholder
=
552 loadTimeData
.getString('url_input_placeholder');
554 // We also need a name for the input for the CSS to work.
555 urlInput
.name
= '-url-input-' + cr
.createUid();
556 replaceAllChildren(assert(urlEl
), urlInput
);
557 urlInput
.value
= url
;
560 var stopPropagation = function(e
) {
565 ['mousedown', 'mouseup', 'contextmenu', 'dblclick', 'paste'];
566 eventsToStop
.forEach(function(type
) {
567 labelInput
.addEventListener(type
, stopPropagation
);
569 labelInput
.addEventListener('keydown', handleKeydown
);
570 labelInput
.addEventListener('blur', handleBlur
);
571 cr
.ui
.limitInputWidth(labelInput
, this, 100, 0.5);
576 eventsToStop
.forEach(function(type
) {
577 urlInput
.addEventListener(type
, stopPropagation
);
579 urlInput
.addEventListener('keydown', handleKeydown
);
580 urlInput
.addEventListener('blur', handleBlur
);
581 cr
.ui
.limitInputWidth(urlInput
, this, 200, 0.5);
585 // Check that we have a valid URL and if not we do not change the
588 var urlInput
= this.querySelector('.url input');
589 var newUrl
= urlInput
.value
;
591 cr
.dispatchSimpleEvent(this, 'canceledit', true);
595 newUrl
= getValidURL(urlInput
);
597 // In case the item was removed before getting here we should
599 if (listItem
.parentNode
) {
600 // Select the item again.
601 var dataModel
= this.parentNode
.dataModel
;
602 var index
= dataModel
.indexOf(this.bookmarkNode
);
603 var sm
= this.parentNode
.selectionModel
;
604 sm
.selectedIndex
= sm
.leadIndex
= sm
.anchorIndex
= index
;
606 alert(loadTimeData
.getString('invalid_url'));
612 urlEl
.textContent
= this.bookmarkNode
.url
= newUrl
;
615 this.removeAttribute('editing');
616 this.draggable
= true;
618 labelInput
= this.querySelector('.label input');
619 var newLabel
= labelInput
.value
;
620 labelEl
.textContent
= this.bookmarkNode
.title
= newLabel
;
623 if (newLabel
!= title
) {
624 cr
.dispatchSimpleEvent(this, 'rename', true);
626 } else if (newLabel
!= title
|| newUrl
!= url
) {
627 cr
.dispatchSimpleEvent(this, 'edit', true);
634 BookmarkList
: BookmarkList
,
635 list
: /** @type {Element} */(null), // Set when decorated.