blob: 27c8e39862080321f83cd5077bfee60d40158469 [file] [log] [blame]
// Copyright (c) 2012 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.
// 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.
/**
* @typedef {{childIds: Array<string>}}
*
* @see chrome/common/extensions/api/bookmarks.json
*/
var ReorderInfo;
/**
* @typedef {{parentId: string,
* index: number,
* oldParentId: string,
* oldIndex: number}}
*
* @see chrome/common/extensions/api/bookmarks.json
*/
var MoveInfo;
cr.define('bmm', function() {
'use strict';
var List = cr.ui.List;
var ListItem = cr.ui.ListItem;
var ArrayDataModel = cr.ui.ArrayDataModel;
var ContextMenuButton = cr.ui.ContextMenuButton;
/**
* Basic array data model for use with bookmarks.
* @param {!Array<!BookmarkTreeNode>} items The bookmark items.
* @constructor
* @extends {ArrayDataModel}
*/
function BookmarksArrayDataModel(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) {
for (var i = 0; i < this.length; i++) {
if (this.item(i).id == id)
return i;
}
return -1;
}
};
/**
* Removes all children and appends a new child.
* @param {!Node} parent The node to remove all children from.
* @param {!Node} newChild The new child to append.
*/
function replaceAllChildren(parent, newChild) {
var n;
while ((n = parent.lastChild)) {
parent.removeChild(n);
}
parent.appendChild(newChild);
}
/**
* Creates a new bookmark list.
* @param {Object=} opt_propertyBag Optional properties.
* @constructor
* @extends {cr.ui.List}
*/
var BookmarkList = cr.ui.define('list');
BookmarkList.prototype = {
__proto__: List.prototype,
/** @override */
decorate: function() {
List.prototype.decorate.call(this);
this.addEventListener('mousedown', this.handleMouseDown_);
// HACK(arv): http://crbug.com/40902
window.addEventListener('resize', this.redraw.bind(this));
// We could add the ContextMenuButton in the BookmarkListItem but it slows
// down redraws a lot so we do this on mouseovers instead.
this.addEventListener('mouseover', this.handleMouseOver_.bind(this));
bmm.list = this;
},
/**
* @param {!BookmarkTreeNode} bookmarkNode
* @override
*/
createItem: function(bookmarkNode) {
return new BookmarkListItem(bookmarkNode);
},
/** @private {string} */
parentId_: '',
/** @private {number} */
loadCount_: 0,
/**
* Reloads the list from the bookmarks backend.
*/
reload: function() {
var parentId = this.parentId;
var callback = this.handleBookmarkCallback_.bind(this);
this.loadCount_++;
if (!parentId)
callback([]);
else if (/^q=/.test(parentId))
chrome.bookmarks.search(parentId.slice(2), callback);
else
chrome.bookmarks.getChildren(parentId, callback);
},
/**
* Callback function for loading items.
* @param {Array<!BookmarkTreeNode>} items The loaded items.
* @private
*/
handleBookmarkCallback_: function(items) {
this.loadCount_--;
if (this.loadCount_)
return;
if (!items) {
// Failed to load bookmarks. Most likely due to the bookmark being
// removed.
cr.dispatchSimpleEvent(this, 'invalidId');
return;
}
this.dataModel = new BookmarksArrayDataModel(items);
this.fixWidth_();
cr.dispatchSimpleEvent(this, 'load');
},
/**
* The bookmark node that the list is currently displaying. If we are
* currently displaying search this returns null.
* @type {BookmarkTreeNode}
*/
get bookmarkNode() {
if (this.isSearch())
return null;
var treeItem = bmm.treeLookup[this.parentId];
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 editing an ephemeral item.
*/
hasEphemeral: function() {
var dataModel = this.dataModel;
for (var i = 0; i < dataModel.array_.length; i++) {
if (dataModel.array_[i].id == 'new')
return true;
}
return false;
},
/**
* Handles mouseover on the list so that we can add the context menu button
* lazily.
* @private
* @param {!Event} e The mouseover event object.
*/
handleMouseOver_: function(e) {
var el = e.target;
while (el && el.parentNode != this) {
el = el.parentNode;
}
if (el && el.parentNode == this &&
!el.editing &&
!(el.lastChild instanceof ContextMenuButton)) {
el.appendChild(new ContextMenuButton);
}
},
/**
* Dispatches an urlClicked event which is used to open URLs in new
* tabs etc.
* @private
* @param {string} url The URL that was clicked.
* @param {!Event} originalEvent The original click event object.
*/
dispatchUrlClickedEvent_: function(url, originalEvent) {
var event = new Event('urlClicked', {bubbles: true});
event.url = url;
event.originalEvent = originalEvent;
this.dispatchEvent(event);
},
/**
* Handles mousedown events so that we can prevent the auto scroll as
* necessary.
* @private
* @param {!Event} e The mousedown event object.
*/
handleMouseDown_: function(e) {
e = /** @type {!MouseEvent} */(e);
if (e.button == 1) {
// WebKit no longer fires click events for middle clicks so we manually
// listen to mouse up to dispatch a click event.
this.addEventListener('mouseup', this.handleMiddleMouseUp_);
// When the user does a middle click we need to prevent the auto scroll
// in case the user is trying to middle click to open a bookmark in a
// background tab.
// We do not do this in case the target is an input since middle click
// is also paste on Linux and we don't want to break that.
if (e.target.tagName != 'INPUT')
e.preventDefault();
}
},
/**
* WebKit no longer dispatches click events for middle clicks so we need
* to emulate it.
* @private
* @param {!Event} e The mouse up event object.
*/
handleMiddleMouseUp_: function(e) {
e = /** @type {!MouseEvent} */(e);
this.removeEventListener('mouseup', this.handleMiddleMouseUp_);
if (e.button == 1) {
var el = e.target;
while (el.parentNode != this) {
el = el.parentNode;
}
var node = el.bookmarkNode;
if (node && !bmm.isFolder(node))
this.dispatchUrlClickedEvent_(node.url, e);
}
e.preventDefault();
},
// Bookmark model update callbacks
handleBookmarkChanged: function(id, changeInfo) {
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)
bookmarkNode.url = changeInfo['url'];
dataModel.updateIndex(index);
}
},
/**
* @param {string} id
* @param {ReorderInfo} reorderInfo
*/
handleChildrenReordered: function(id, reorderInfo) {
if (this.parentId == id) {
// 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]];
newArray[i].index = i;
}
this.dataModel = new BookmarksArrayDataModel(newArray);
}
},
handleCreated: function(id, bookmarkNode) {
if (this.parentId == bookmarkNode.parentId)
this.dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
},
/**
* @param {string} id
* @param {MoveInfo} moveInfo
*/
handleMoved: function(id, moveInfo) {
if (moveInfo.parentId == this.parentId ||
moveInfo.oldParentId == this.parentId) {
var dataModel = this.dataModel;
if (moveInfo.oldParentId == moveInfo.parentId) {
// 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) {
// Move out of this folder
var index = dataModel.findIndexById(id);
if (index != -1)
dataModel.splice(index, 1);
}
if (moveInfo.parentId == this.parentId) {
// Move to this folder
var self = this;
chrome.bookmarks.get(id, function(bookmarkNodes) {
var bookmarkNode = bookmarkNodes[0];
dataModel.splice(bookmarkNode.index, 0, bookmarkNode);
});
}
}
}
},
handleRemoved: function(id, removeInfo) {
var dataModel = this.dataModel;
var index = dataModel.findIndexById(id);
if (index != -1)
dataModel.splice(index, 1);
},
/**
* Workaround for http://crbug.com/40902
* @private
*/
fixWidth_: function() {
var list = bmm.list;
if (this.loadCount_ || !list)
return;
// The width of the list is wrong after its content has changed.
// Fortunately the reported offsetWidth is correct so we can detect the
//incorrect width.
if (list.offsetWidth != list.parentNode.clientWidth - list.offsetLeft) {
// Set the width to the correct size. This causes the relayout.
list.style.width = list.parentNode.clientWidth - list.offsetLeft + 'px';
// Remove the temporary style.width in a timeout. Once the timer fires
// the size should not change since we already fixed the width.
window.setTimeout(function() {
list.style.width = '';
}, 0);
}
}
};
/**
* The ID of the bookmark folder we are displaying.
*/
cr.defineProperty(BookmarkList, 'parentId', cr.PropertyKind.JS,
function() {
this.reload();
});
/**
* The contextMenu property.
*/
cr.ui.contextMenuHandler.addContextMenuProperty(BookmarkList);
/** @type {cr.ui.Menu} */
BookmarkList.prototype.contextMenu;
/**
* Creates a new bookmark list item.
* @param {!BookmarkTreeNode} bookmarkNode The bookmark node this represents.
* @constructor
* @extends {cr.ui.ListItem}
*/
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,
/** @override */
decorate: function() {
ListItem.prototype.decorate.call(this);
var bookmarkNode = this.bookmarkNode;
this.draggable = true;
var labelEl = this.ownerDocument.createElement('div');
labelEl.className = 'label';
labelEl.textContent = bookmarkNode.title;
var urlEl = this.ownerDocument.createElement('div');
urlEl.className = 'url';
if (bmm.isFolder(bookmarkNode)) {
this.className = 'folder';
} else {
labelEl.style.backgroundImage = cr.icon.getFaviconImageSet(
bookmarkNode.url);
labelEl.style.backgroundSize = '16px';
urlEl.textContent = bookmarkNode.url;
}
this.appendChild(labelEl);
this.appendChild(urlEl);
// Initially the ContextMenuButton was added here but it slowed down
// rendering a lot so it is now added using mouseover.
},
/**
* 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}
*/
get editing() {
return this.hasAttribute('editing');
},
set editing(editing) {
var oldEditing = this.editing;
if (oldEditing == editing)
return;
var url = this.bookmarkNode.url;
var title = this.bookmarkNode.title;
var isFolder = bmm.isFolder(this.bookmarkNode);
var listItem = this;
var labelEl = this.firstChild;
var urlEl = labelEl.nextSibling;
var labelInput, urlInput;
// Handles enter and escape which trigger reset and commit respectively.
function handleKeydown(e) {
// Make sure that the tree does not handle the key.
e.stopPropagation();
// Calling list.focus blurs the input which will stop editing the list
// item.
switch (e.key) {
case 'Escape': // Esc
labelInput.value = title;
if (!isFolder)
urlInput.value = url;
// fall through
cr.dispatchSimpleEvent(listItem, 'canceledit', true);
case 'Enter':
if (listItem.parentNode)
listItem.parentNode.focus();
break;
case 'Tab': // Tab
// urlInput is the last focusable element in the page. If we
// allowed Tab focus navigation and the page loses focus, we
// couldn't give focus on urlInput programatically. So, we prevent
// Tab focus navigation.
if (document.activeElement == urlInput && !e.ctrlKey &&
!e.metaKey && !e.shiftKey && !getValidURL(urlInput)) {
e.preventDefault();
urlInput.blur();
}
break;
}
}
function getValidURL(input) {
var originalValue = input.value;
if (!originalValue)
return null;
if (input.validity.valid)
return originalValue;
// Blink does not do URL fix up so we manually test if prepending
// 'http://' would make the URL valid.
// https://bugs.webkit.org/show_bug.cgi?id=29235
input.value = 'http://' + originalValue;
if (input.validity.valid)
return input.value;
// still invalid
input.value = originalValue;
return null;
}
function handleBlur(e) {
// When the blur event happens we do not know who is getting focus so we
// delay this a bit since we want to know if the other input got focus
// before deciding if we should exit edit mode.
var doc = e.target.ownerDocument;
window.setTimeout(function() {
var activeElement = doc.hasFocus() && doc.activeElement;
if (activeElement != urlInput && activeElement != labelInput) {
listItem.editing = false;
}
}, 50);
}
var doc = this.ownerDocument;
if (editing) {
this.setAttribute('editing', '');
this.draggable = false;
labelInput = /** @type {HTMLElement} */(doc.createElement('input'));
labelInput.placeholder =
loadTimeData.getString('name_input_placeholder');
replaceAllChildren(labelEl, labelInput);
labelInput.value = title;
if (!isFolder) {
urlInput = /** @type {HTMLElement} */(doc.createElement('input'));
urlInput.type = 'url';
urlInput.required = true;
urlInput.placeholder =
loadTimeData.getString('url_input_placeholder');
// We also need a name for the input for the CSS to work.
urlInput.name = '-url-input-' + cr.createUid();
replaceAllChildren(assert(urlEl), urlInput);
urlInput.value = url;
}
var stopPropagation = function(e) {
e.stopPropagation();
};
var eventsToStop =
['mousedown', 'mouseup', 'contextmenu', 'dblclick', 'paste'];
eventsToStop.forEach(function(type) {
labelInput.addEventListener(type, stopPropagation);
});
labelInput.addEventListener('keydown', handleKeydown);
labelInput.addEventListener('blur', handleBlur);
cr.ui.limitInputWidth(labelInput, this, 100, 0.5);
labelInput.focus();
labelInput.select();
if (!isFolder) {
eventsToStop.forEach(function(type) {
urlInput.addEventListener(type, stopPropagation);
});
urlInput.addEventListener('keydown', handleKeydown);
urlInput.addEventListener('blur', handleBlur);
cr.ui.limitInputWidth(urlInput, this, 200, 0.5);
}
} else {
// Check that we have a valid URL and if not we do not change the
// editing mode.
if (!isFolder) {
var urlInput = this.querySelector('.url input');
var newUrl = urlInput.value;
if (!newUrl) {
cr.dispatchSimpleEvent(this, 'canceledit', true);
return;
}
newUrl = getValidURL(urlInput);
if (!newUrl) {
// In case the item was removed before getting here we should
// not alert.
if (listItem.parentNode) {
// Select the item again.
var dataModel = this.parentNode.dataModel;
var index = dataModel.indexOf(this.bookmarkNode);
var sm = this.parentNode.selectionModel;
sm.selectedIndex = sm.leadIndex = sm.anchorIndex = index;
alert(loadTimeData.getString('invalid_url'));
}
urlInput.focus();
urlInput.select();
return;
}
urlEl.textContent = this.bookmarkNode.url = newUrl;
}
this.removeAttribute('editing');
this.draggable = true;
labelInput = this.querySelector('.label input');
var newLabel = labelInput.value;
labelEl.textContent = this.bookmarkNode.title = newLabel;
if (isFolder) {
if (newLabel != title) {
cr.dispatchSimpleEvent(this, 'rename', true);
}
} else if (newLabel != title || newUrl != url) {
cr.dispatchSimpleEvent(this, 'edit', true);
}
}
}
};
return {
BookmarkList: BookmarkList,
list: /** @type {Element} */(null), // Set when decorated.
};
});