diff options
Diffstat (limited to 'browser/components/places')
25 files changed, 12825 insertions, 0 deletions
diff --git a/browser/components/places/PlacesUIUtils.jsm b/browser/components/places/PlacesUIUtils.jsm new file mode 100644 index 000000000..8a7d4a00f --- /dev/null +++ b/browser/components/places/PlacesUIUtils.jsm @@ -0,0 +1,1375 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +this.EXPORTED_SYMBOLS = ["PlacesUIUtils"]; + +var Ci = Components.interfaces; +var Cc = Components.classes; +var Cr = Components.results; +var Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", + "resource://gre/modules/PluralForm.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow", + "resource:///modules/RecentWindow.jsm"); + +XPCOMUtils.defineLazyGetter(this, "PlacesUtils", function() { + Cu.import("resource://gre/modules/PlacesUtils.jsm"); + return PlacesUtils; +}); + +this.PlacesUIUtils = { + ORGANIZER_LEFTPANE_VERSION: 7, + ORGANIZER_FOLDER_ANNO: "PlacesOrganizer/OrganizerFolder", + ORGANIZER_QUERY_ANNO: "PlacesOrganizer/OrganizerQuery", + + LOAD_IN_SIDEBAR_ANNO: "bookmarkProperties/loadInSidebar", + DESCRIPTION_ANNO: "bookmarkProperties/description", + + TYPE_TAB_DROP: "application/x-moz-tabbrowser-tab", + + /** + * Makes a URI from a spec, and do fixup + * @param aSpec + * The string spec of the URI + * @returns A URI object for the spec. + */ + createFixedURI: function(aSpec) { + return URIFixup.createFixupURI(aSpec, Ci.nsIURIFixup.FIXUP_FLAG_NONE); + }, + + getFormattedString: function(key, params) { + return bundle.formatStringFromName(key, params, params.length); + }, + + /** + * Get a localized plural string for the specified key name and numeric value + * substituting parameters. + * + * @param aKey + * String, key for looking up the localized string in the bundle + * @param aNumber + * Number based on which the final localized form is looked up + * @param aParams + * Array whose items will substitute #1, #2,... #n parameters + * in the string. + * + * @see https://developer.mozilla.org/en/Localization_and_Plurals + * @return The localized plural string. + */ + getPluralString: function(aKey, aNumber, aParams) { + let str = PluralForm.get(aNumber, bundle.GetStringFromName(aKey)); + + // Replace #1 with aParams[0], #2 with aParams[1], and so on. + return str.replace(/\#(\d+)/g, function(matchedId, matchedNumber) { + let param = aParams[parseInt(matchedNumber, 10) - 1]; + return param !== undefined ? param : matchedId; + }); + }, + + getString: function(key) { + return bundle.GetStringFromName(key); + }, + + get _copyableAnnotations() [ + this.DESCRIPTION_ANNO, + this.LOAD_IN_SIDEBAR_ANNO, + PlacesUtils.POST_DATA_ANNO, + PlacesUtils.READ_ONLY_ANNO, + ], + + /** + * Get a transaction for copying a uri item (either a bookmark or a history + * entry) from one container to another. + * + * @param aData + * JSON object of dropped or pasted item properties + * @param aContainer + * The container being copied into + * @param aIndex + * The index within the container the item is copied to + * @return A nsITransaction object that performs the copy. + * + * @note Since a copy creates a completely new item, only some internal + * annotations are synced from the old one. + * @see this._copyableAnnotations for the list of copyable annotations. + */ + _getURIItemCopyTransaction: + function(aData, aContainer, aIndex) + { + let transactions = []; + if (aData.dateAdded) { + transactions.push( + new PlacesEditItemDateAddedTransaction(null, aData.dateAdded) + ); + } + if (aData.lastModified) { + transactions.push( + new PlacesEditItemLastModifiedTransaction(null, aData.lastModified) + ); + } + + let keyword = aData.keyword || null; + let annos = []; + if (aData.annos) { + annos = aData.annos.filter(function(aAnno) { + return this._copyableAnnotations.indexOf(aAnno.name) != -1; + }, this); + } + + return new PlacesCreateBookmarkTransaction(PlacesUtils._uri(aData.uri), + aContainer, aIndex, aData.title, + keyword, annos, transactions); + }, + + /** + * Gets a transaction for copying (recursively nesting to include children) + * a folder (or container) and its contents from one folder to another. + * + * @param aData + * Unwrapped dropped folder data - Obj containing folder and children + * @param aContainer + * The container we are copying into + * @param aIndex + * The index in the destination container to insert the new items + * @return A nsITransaction object that will perform the copy. + * + * @note Since a copy creates a completely new item, only some internal + * annotations are synced from the old one. + * @see this._copyableAnnotations for the list of copyable annotations. + */ + _getFolderCopyTransaction(aData, aContainer, aIndex) { + function getChildItemsTransactions(aRoot) { + let transactions = []; + let index = aIndex; + for (let i = 0; i < aRoot.childCount; ++i) { + let child = aRoot.getChild(i); + // Temporary hacks until we switch to PlacesTransactions.jsm. + let isLivemark = + PlacesUtils.annotations.itemHasAnnotation(child.itemId, + PlacesUtils.LMANNO_FEEDURI); + let [node] = PlacesUtils.unwrapNodes( + PlacesUtils.wrapNode(child, PlacesUtils.TYPE_X_MOZ_PLACE, isLivemark), + PlacesUtils.TYPE_X_MOZ_PLACE + ); + + // Make sure that items are given the correct index, this will be + // passed by the transaction manager to the backend for the insertion. + // Insertion behaves differently for DEFAULT_INDEX (append). + if (aIndex != PlacesUtils.bookmarks.DEFAULT_INDEX) { + index = i; + } + + if (node.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) { + if (node.livemark && node.annos) { + transactions.push( + PlacesUIUtils._getLivemarkCopyTransaction(node, aContainer, index) + ); + } + else { + transactions.push( + PlacesUIUtils._getFolderCopyTransaction(node, aContainer, index) + ); + } + } + else if (node.type == PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) { + transactions.push(new PlacesCreateSeparatorTransaction(-1, index)); + } + else if (node.type == PlacesUtils.TYPE_X_MOZ_PLACE) { + transactions.push( + PlacesUIUtils._getURIItemCopyTransaction(node, -1, index) + ); + } + else { + throw new Error("Unexpected item under a bookmarks folder"); + } + } + return transactions; + } + + if (aContainer == PlacesUtils.tagsFolderId) { // Copying into a tag folder. + let transactions = []; + if (!aData.livemark && aData.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) { + let {root} = PlacesUtils.getFolderContents(aData.id, false, false); + let urls = PlacesUtils.getURLsForContainerNode(root); + root.containerOpen = false; + for (let { uri } of urls) { + transactions.push( + new PlacesTagURITransaction(NetUtil.newURI(uri), [aData.title]) + ); + } + } + return new PlacesAggregatedTransaction("addTags", transactions); + } + + if (aData.livemark && aData.annos) { // Copying a livemark. + return this._getLivemarkCopyTransaction(aData, aContainer, aIndex); + } + + let {root} = PlacesUtils.getFolderContents(aData.id, false, false); + let transactions = getChildItemsTransactions(root); + root.containerOpen = false; + + if (aData.dateAdded) { + transactions.push( + new PlacesEditItemDateAddedTransaction(null, aData.dateAdded) + ); + } + if (aData.lastModified) { + transactions.push( + new PlacesEditItemLastModifiedTransaction(null, aData.lastModified) + ); + } + + let annos = []; + if (aData.annos) { + annos = aData.annos.filter(function(aAnno) { + return this._copyableAnnotations.indexOf(aAnno.name) != -1; + }, this); + } + + return new PlacesCreateFolderTransaction(aData.title, aContainer, aIndex, + annos, transactions); + }, + + /** + * Gets a transaction for copying a live bookmark item from one container to + * another. + * + * @param aData + * Unwrapped live bookmarkmark data + * @param aContainer + * The container we are copying into + * @param aIndex + * The index in the destination container to insert the new items + * @return A nsITransaction object that will perform the copy. + * + * @note Since a copy creates a completely new item, only some internal + * annotations are synced from the old one. + * @see this._copyableAnnotations for the list of copyable annotations. + */ + _getLivemarkCopyTransaction: + function(aData, aContainer, aIndex) + { + if (!aData.livemark || !aData.annos) { + throw new Error("node is not a livemark"); + } + + let feedURI, siteURI; + let annos = []; + if (aData.annos) { + annos = aData.annos.filter(function(aAnno) { + if (aAnno.name == PlacesUtils.LMANNO_FEEDURI) { + feedURI = PlacesUtils._uri(aAnno.value); + } + else if (aAnno.name == PlacesUtils.LMANNO_SITEURI) { + siteURI = PlacesUtils._uri(aAnno.value); + } + return this._copyableAnnotations.indexOf(aAnno.name) != -1 + }, this); + } + + return new PlacesCreateLivemarkTransaction(feedURI, siteURI, aData.title, + aContainer, aIndex, annos); + }, + + /** + * Test if a bookmark item = a live bookmark item. + * + * @param aItemId + * item identifier + * @return true if a live bookmark item, false otherwise. + * + * @note Maybe this should be removed later, see bug 1072833. + */ + _isLivemark: + function(aItemId) + { + // Since this check may be done on each dragover event, it's worth maintaining + // a cache. + let self = this._isLivemark; + if (!("ids" in self)) { + const LIVEMARK_ANNO = PlacesUtils.LMANNO_FEEDURI; + + let idsVec = PlacesUtils.annotations.getItemsWithAnnotation(LIVEMARK_ANNO); + self.ids = new Set(idsVec); + + let obs = Object.freeze({ + QueryInterface: XPCOMUtils.generateQI(Ci.nsIAnnotationObserver), + + onItemAnnotationSet(itemId, annoName) { + if (annoName == LIVEMARK_ANNO) + self.ids.add(itemId); + }, + + onItemAnnotationRemoved(itemId, annoName) { + // If annoName is set to an empty string, the item is gone. + if (annoName == LIVEMARK_ANNO || annoName == "") + self.ids.delete(itemId); + }, + + onPageAnnotationSet() { }, + onPageAnnotationRemoved() { }, + }); + PlacesUtils.annotations.addObserver(obs); + PlacesUtils.registerShutdownFunction(() => { + PlacesUtils.annotations.removeObserver(obs); + }); + } + return self.ids.has(aItemId); + }, + + /** + * Constructs a Transaction for the drop or paste of a blob of data into + * a container. + * @param data + * The unwrapped data blob of dropped or pasted data. + * @param type + * The content type of the data + * @param container + * The container the data was dropped or pasted into + * @param index + * The index within the container the item was dropped or pasted at + * @param copy + * The drag action was copy, so don't move folders or links. + * @returns An object implementing nsITransaction that can perform + * the move/insert. + */ + makeTransaction: + function(data, type, container, index, copy) + { + switch (data.type) { + case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: + if (copy) { + return this._getFolderCopyTransaction(data, container, index); + } + + // Otherwise move the item. + return new PlacesMoveItemTransaction(data.id, container, index); + break; + case PlacesUtils.TYPE_X_MOZ_PLACE: + if (copy || data.id == -1) { // Id is -1 if the place is not bookmarked. + return this._getURIItemCopyTransaction(data, container, index); + } + + // Otherwise move the item. + return new PlacesMoveItemTransaction(data.id, container, index); + break; + case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: + if (copy) { + // There is no data in a separator, so copying it just amounts to + // inserting a new separator. + return new PlacesCreateSeparatorTransaction(container, index); + } + + // Otherwise move the item. + return new PlacesMoveItemTransaction(data.id, container, index); + break; + default: + if (type == PlacesUtils.TYPE_X_MOZ_URL || + type == PlacesUtils.TYPE_UNICODE || + type == this.TYPE_TAB_DROP) { + let title = type != PlacesUtils.TYPE_UNICODE ? data.title + : data.uri; + return new PlacesCreateBookmarkTransaction(PlacesUtils._uri(data.uri), + container, index, title); + } + } + return null; + }, + + /** + * Shows the bookmark dialog corresponding to the specified info. + * + * @param aInfo + * Describes the item to be edited/added in the dialog. + * See documentation at the top of bookmarkProperties.js + * @param aWindow + * Owner window for the new dialog. + * + * @see documentation at the top of bookmarkProperties.js + * @return true if any transaction has been performed, false otherwise. + */ + showBookmarkDialog: + function(aInfo, aParentWindow) { + // Preserve size attributes differently based on the fact the dialog has + // a folder picker or not, since it needs more horizontal space than the + // other controls. + let hasFolderPicker = !("hiddenRows" in aInfo) || + aInfo.hiddenRows.indexOf("folderPicker") == -1; + // Use a different chrome url to persist different sizes. + let dialogURL = hasFolderPicker ? + "chrome://browser/content/places/bookmarkProperties2.xul" : + "chrome://browser/content/places/bookmarkProperties.xul"; + + let features = "centerscreen,chrome,modal,resizable=yes"; + aParentWindow.openDialog(dialogURL, "", features, aInfo); + return ("performed" in aInfo && aInfo.performed); + }, + + _getTopBrowserWin: function() { + return RecentWindow.getMostRecentBrowserWindow(); + }, + + /** + * Returns the closet ancestor places view for the given DOM node + * @param aNode + * a DOM node + * @return the closet ancestor places view if exists, null otherwsie. + */ + getViewForNode: function(aNode) { + let node = aNode; + + // The view for a <menu> of which its associated menupopup is a places + // view, is the menupopup. + if (node.localName == "menu" && !node._placesNode && + node.lastChild._placesView) + return node.lastChild._placesView; + + while (node instanceof Ci.nsIDOMElement) { + if (node._placesView) + return node._placesView; + if (node.localName == "tree" && node.getAttribute("type") == "places") + return node; + + node = node.parentNode; + } + + return null; + }, + + /** + * By calling this before visiting an URL, the visit will be associated to a + * TRANSITION_TYPED transition (if there is no a referrer). + * This is used when visiting pages from the history menu, history sidebar, + * url bar, url autocomplete results, and history searches from the places + * organizer. If this is not called visits will be marked as + * TRANSITION_LINK. + */ + markPageAsTyped: function(aURL) { + PlacesUtils.history.markPageAsTyped(this.createFixedURI(aURL)); + }, + + /** + * By calling this before visiting an URL, the visit will be associated to a + * TRANSITION_BOOKMARK transition. + * This is used when visiting pages from the bookmarks menu, + * personal toolbar, and bookmarks from within the places organizer. + * If this is not called visits will be marked as TRANSITION_LINK. + */ + markPageAsFollowedBookmark: function(aURL) { + PlacesUtils.history.markPageAsFollowedBookmark(this.createFixedURI(aURL)); + }, + + /** + * By calling this before visiting an URL, any visit in frames will be + * associated to a TRANSITION_FRAMED_LINK transition. + * This is actually used to distinguish user-initiated visits in frames + * so automatic visits can be correctly ignored. + */ + markPageAsFollowedLink: function(aURL) { + PlacesUtils.history.markPageAsFollowedLink(this.createFixedURI(aURL)); + }, + + /** + * Allows opening of javascript/data URI only if the given node is + * bookmarked (see bug 224521). + * @param aURINode + * a URI node + * @param aWindow + * a window on which a potential error alert is shown on. + * @return true if it's safe to open the node in the browser, false otherwise. + * + */ + checkURLSecurity: function(aURINode, aWindow) { + if (PlacesUtils.nodeIsBookmark(aURINode)) + return true; + + var uri = PlacesUtils._uri(aURINode.uri); + if (uri.schemeIs("javascript") || uri.schemeIs("data")) { + const BRANDING_BUNDLE_URI = "chrome://branding/locale/brand.properties"; + var brandShortName = Cc["@mozilla.org/intl/stringbundle;1"]. + getService(Ci.nsIStringBundleService). + createBundle(BRANDING_BUNDLE_URI). + GetStringFromName("brandShortName"); + + var errorStr = this.getString("load-js-data-url-error"); + Services.prompt.alert(aWindow, brandShortName, errorStr); + return false; + } + return true; + }, + + /** + * Get the description associated with a document, as specified in a <META> + * element. + * @param doc + * A DOM Document to get a description for + * @returns A description string if a META element was discovered with a + * "description" or "httpequiv" attribute, empty string otherwise. + */ + getDescriptionFromDocument: function(doc) { + var metaElements = doc.getElementsByTagName("META"); + for (var i = 0; i < metaElements.length; ++i) { + if (metaElements[i].name.toLowerCase() == "description" || + metaElements[i].httpEquiv.toLowerCase() == "description") { + return metaElements[i].content; + } + } + return ""; + }, + + /** + * Retrieve the description of an item + * @param aItemId + * item identifier + * @returns the description of the given item, or an empty string if it is + * not set. + */ + getItemDescription: function(aItemId) { + if (PlacesUtils.annotations.itemHasAnnotation(aItemId, this.DESCRIPTION_ANNO)) + return PlacesUtils.annotations.getItemAnnotation(aItemId, this.DESCRIPTION_ANNO); + return ""; + }, + + /** + * Check whether or not the given node represents a removable entry (either in + * history or in bookmarks). + * + * @param aNode + * a node, except the root node of a query. + * @return true if the aNode represents a removable entry, false otherwise. + */ + canUserRemove: function(aNode) { + let parentNode = aNode.parent; + if (!parentNode) + throw new Error("canUserRemove doesn't accept root nodes"); + + // If it's not a bookmark, we can remove it unless it's a child of a + // livemark. + if (aNode.itemId == -1) { + // Rather than executing a db query, checking the existence of the feedURI + // annotation, detect livemark children by the fact that they are the only + // direct non-bookmark children of bookmark folders. + return !PlacesUtils.nodeIsFolder(parentNode); + } + + // Generally it's always possible to remove children of a query. + if (PlacesUtils.nodeIsQuery(parentNode)) + return true; + + // Otherwise it has to be a child of an editable folder. + return !this.isContentsReadOnly(parentNode); + }, + + /** + * DO NOT USE THIS API IN ADDONS. IT IS VERY LIKELY TO CHANGE WHEN THE SWITCH + * TO GUIDS IS COMPLETE (BUG 1071511). + * + * Check whether or not the given node or item-id points to a folder which + * should not be modified by the user (i.e. its children should be unremovable + * and unmovable, new children should be disallowed, etc). + * These semantics are not inherited, meaning that read-only folder may + * contain editable items (for instance, the places root is read-only, but all + * of its direct children aren't). + * + * You should only pass folder item ids or folder nodes for aNodeOrItemId. + * While this is only enforced for the node case (if an item id of a separator + * or a bookmark is passed, false is returned), it's considered the caller's + * job to ensure that it checks a folder. + * Also note that folder-shortcuts should only be passed as result nodes. + * Otherwise they are just treated as bookmarks (i.e. false is returned). + * + * @param aNodeOrItemId + * any item id or result node. + * @throws if aNodeOrItemId is neither an item id nor a folder result node. + * @note livemark "folders" are considered read-only (but see bug 1072833). + * @return true if aItemId points to a read-only folder, false otherwise. + */ + isContentsReadOnly: function(aNodeOrItemId) { + let itemId; + if (typeof(aNodeOrItemId) == "number") { + itemId = aNodeOrItemId; + } + else if (PlacesUtils.nodeIsFolder(aNodeOrItemId)) { + itemId = PlacesUtils.getConcreteItemId(aNodeOrItemId); + } + else { + throw new Error("invalid value for aNodeOrItemId"); + } + + if (itemId == PlacesUtils.placesRootId || this._isLivemark(itemId)) + return true; + + // leftPaneFolderId, and as a result, allBookmarksFolderId, is a lazy getter + // performing at least a synchronous DB query (and on its very first call + // in a fresh profile, it also creates the entire structure). + // Therefore we don't want to this function, which is called very often by + // isCommandEnabled, to ever be the one that invokes it first, especially + // because isCommandEnabled may be called way before the left pane folder is + // even created (for example, if the user only uses the bookmarks menu or + // toolbar for managing bookmarks). To do so, we avoid comparing to those + // special folder if the lazy getter is still in place. This is safe merely + // because the only way to access the left pane contents goes through + // "resolving" the leftPaneFolderId getter. + if ("get" in Object.getOwnPropertyDescriptor(this, "leftPaneFolderId")) + return false; + + return itemId == this.leftPaneFolderId || + itemId == this.allBookmarksFolderId; + }, + + /** + * Gives the user a chance to cancel loading lots of tabs at once + */ + _confirmOpenInTabs: + function(numTabsToOpen, aWindow) { + const WARN_ON_OPEN_PREF = "browser.tabs.warnOnOpen"; + var reallyOpen = true; + + if (Services.prefs.getBoolPref(WARN_ON_OPEN_PREF)) { + if (numTabsToOpen >= Services.prefs.getIntPref("browser.tabs.maxOpenBeforeWarn")) { + // default to true: if it were false, we wouldn't get this far + var warnOnOpen = { value: true }; + + var messageKey = "tabs.openWarningMultipleBranded"; + var openKey = "tabs.openButtonMultiple"; + const BRANDING_BUNDLE_URI = "chrome://branding/locale/brand.properties"; + var brandShortName = Cc["@mozilla.org/intl/stringbundle;1"]. + getService(Ci.nsIStringBundleService). + createBundle(BRANDING_BUNDLE_URI). + GetStringFromName("brandShortName"); + + var buttonPressed = Services.prompt.confirmEx( + aWindow, + this.getString("tabs.openWarningTitle"), + this.getFormattedString(messageKey, [numTabsToOpen, brandShortName]), + (Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0) + + (Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1), + this.getString(openKey), null, null, + this.getFormattedString("tabs.openWarningPromptMeBranded", + [brandShortName]), + warnOnOpen + ); + + reallyOpen = (buttonPressed == 0); + // don't set the pref unless they press OK and it's false + if (reallyOpen && !warnOnOpen.value) + Services.prefs.setBoolPref(WARN_ON_OPEN_PREF, false); + } + } + + return reallyOpen; + }, + + /** aItemsToOpen needs to be an array of objects of the form: + * {uri: string, isBookmark: boolean} + */ + _openTabset: function(aItemsToOpen, aEvent, aWindow) { + if (!aItemsToOpen.length) + return; + + // Prefer the caller window if it's a browser window, otherwise use + // the top browser window. + var browserWindow = null; + browserWindow = + aWindow && aWindow.document.documentElement.getAttribute("windowtype") == "navigator:browser" ? + aWindow : this._getTopBrowserWin(); + + var urls = []; + let skipMarking = browserWindow && PrivateBrowsingUtils.isWindowPrivate(browserWindow); + for (let item of aItemsToOpen) { + urls.push(item.uri); + if (skipMarking) { + continue; + } + + if (item.isBookmark) + this.markPageAsFollowedBookmark(item.uri); + else + this.markPageAsTyped(item.uri); + } + + // whereToOpenLink doesn't return "window" when there's no browser window + // open (Bug 630255). + var where = browserWindow ? + browserWindow.whereToOpenLink(aEvent, false, true) : "window"; + if (where == "window") { + // There is no browser window open, thus open a new one. + var uriList = PlacesUtils.toISupportsString(urls.join("|")); + var args = Cc["@mozilla.org/supports-array;1"]. + createInstance(Ci.nsISupportsArray); + args.AppendElement(uriList); + browserWindow = Services.ww.openWindow(aWindow, + "chrome://browser/content/browser.xul", + null, "chrome,dialog=no,all", args); + return; + } + + var loadInBackground = where == "tabshifted" ? true : false; + // For consistency, we want all the bookmarks to open in new tabs, instead + // of having one of them replace the currently focused tab. Hence we call + // loadTabs with aReplace set to false. + browserWindow.gBrowser.loadTabs(urls, loadInBackground, false); + }, + + openLiveMarkNodesInTabs: + function(aNode, aEvent, aView) { + let window = aView.ownerWindow; + + PlacesUtils.livemarks.getLivemark({id: aNode.itemId}) + .then(aLivemark => { + urlsToOpen = []; + + let nodes = aLivemark.getNodesForContainer(aNode); + for (let node of nodes) { + urlsToOpen.push({uri: node.uri, isBookmark: false}); + } + + if (this._confirmOpenInTabs(urlsToOpen.length, window)) { + this._openTabset(urlsToOpen, aEvent, window); + } + }, Cu.reportError); + }, + + openContainerNodeInTabs: + function(aNode, aEvent, aView) { + let window = aView.ownerWindow; + + let urlsToOpen = PlacesUtils.getURLsForContainerNode(aNode); + if (this._confirmOpenInTabs(urlsToOpen.length, window)) { + this._openTabset(urlsToOpen, aEvent, window); + } + }, + + openURINodesInTabs: function(aNodes, aEvent, aView) { + let window = aView.ownerWindow; + + let urlsToOpen = []; + for (var i=0; i < aNodes.length; i++) { + // Skip over separators and folders. + if (PlacesUtils.nodeIsURI(aNodes[i])) + urlsToOpen.push({uri: aNodes[i].uri, isBookmark: PlacesUtils.nodeIsBookmark(aNodes[i])}); + } + if (this._confirmOpenInTabs(urlsToOpen.length, window)) { + this._openTabset(urlsToOpen, aEvent, window); + } + }, + + /** + * Loads the node's URL in the appropriate tab or window or as a web + * panel given the user's preference specified by modifier keys tracked by a + * DOM mouse/key event. + * @param aNode + * An uri result node. + * @param aEvent + * The DOM mouse/key event with modifier keys set that track the + * user's preferred destination window or tab. + * @param aView + * The controller associated with aNode. + */ + openNodeWithEvent: + function(aNode, aEvent, aView) { + let window = aView.ownerWindow; + this._openNodeIn(aNode, window.whereToOpenLink(aEvent, false, true), window); + }, + + /** + * Loads the node's URL in the appropriate tab or window or as a + * web panel. + * see also openUILinkIn + */ + openNodeIn: function(aNode, aWhere, aView, aPrivate) { + let window = aView.ownerWindow; + this._openNodeIn(aNode, aWhere, window, aPrivate); + }, + + _openNodeIn: function(aNode, aWhere, aWindow, aPrivate=false) { + if (aNode && PlacesUtils.nodeIsURI(aNode) && + this.checkURLSecurity(aNode, aWindow)) { + let isBookmark = PlacesUtils.nodeIsBookmark(aNode); + + if (!PrivateBrowsingUtils.isWindowPrivate(aWindow)) { + if (isBookmark) + this.markPageAsFollowedBookmark(aNode.uri); + else + this.markPageAsTyped(aNode.uri); + } + + // Check whether the node is a bookmark which should be opened as + // a web panel + if (aWhere == "current" && isBookmark) { + if (PlacesUtils.annotations + .itemHasAnnotation(aNode.itemId, this.LOAD_IN_SIDEBAR_ANNO)) { + let browserWin = this._getTopBrowserWin(); + if (browserWin) { + browserWin.openWebPanel(aNode.title, aNode.uri); + return; + } + } + } + aWindow.openUILinkIn(aNode.uri, aWhere, { + inBackground: Services.prefs.getBoolPref("browser.tabs.loadBookmarksInBackground"), + private: aPrivate, + }); + } + }, + + /** + * Helper for guessing scheme from an url string. + * Used to avoid nsIURI overhead in frequently called UI functions. + * + * @param aUrlString the url to guess the scheme from. + * + * @return guessed scheme for this url string. + * + * @note this is not supposed be perfect, so use it only for UI purposes. + */ + guessUrlSchemeForUI: function(aUrlString) { + return aUrlString.substr(0, aUrlString.indexOf(":")); + }, + + getBestTitle: function(aNode, aDoNotCutTitle) { + var title; + if (!aNode.title && PlacesUtils.nodeIsURI(aNode)) { + // if node title is empty, try to set the label using host and filename + // PlacesUtils._uri() will throw if aNode.uri is not a valid URI + try { + var uri = PlacesUtils._uri(aNode.uri); + var host = uri.host; + var fileName = uri.QueryInterface(Ci.nsIURL).fileName; + // if fileName is empty, use path to distinguish labels + if (aDoNotCutTitle) { + title = host + uri.path; + } else { + title = host + (fileName ? + (host ? "/" + this.ellipsis + "/" : "") + fileName : + uri.path); + } + } + catch (e) { + // Use (no title) for non-standard URIs (data:, javascript:, ...) + title = ""; + } + } + else + title = aNode.title; + + return title || this.getString("noTitle"); + }, + + get leftPaneQueries() { + // build the map + this.leftPaneFolderId; + return this.leftPaneQueries; + }, + + // Get the folder id for the organizer left-pane folder. + get leftPaneFolderId() { + let leftPaneRoot = -1; + let allBookmarksId; + + // Shortcuts to services. + let bs = PlacesUtils.bookmarks; + let as = PlacesUtils.annotations; + + // This is the list of the left pane queries. + let queries = { + "PlacesRoot": { title: "" }, + "History": { title: this.getString("OrganizerQueryHistory") }, + "Downloads": { title: this.getString("OrganizerQueryDownloads") }, + "Tags": { title: this.getString("OrganizerQueryTags") }, + "AllBookmarks": { title: this.getString("OrganizerQueryAllBookmarks") }, + "BookmarksToolbar": + { title: null, + concreteTitle: PlacesUtils.getString("BookmarksToolbarFolderTitle"), + concreteId: PlacesUtils.toolbarFolderId }, + "BookmarksMenu": + { title: null, + concreteTitle: PlacesUtils.getString("BookmarksMenuFolderTitle"), + concreteId: PlacesUtils.bookmarksMenuFolderId }, + "UnfiledBookmarks": + { title: null, + concreteTitle: PlacesUtils.getString("UnsortedBookmarksFolderTitle"), + concreteId: PlacesUtils.unfiledBookmarksFolderId }, + }; + // All queries but PlacesRoot. + const EXPECTED_QUERY_COUNT = 7; + + // Removes an item and associated annotations, ignoring eventual errors. + function safeRemoveItem(aItemId) { + try { + if (as.itemHasAnnotation(aItemId, PlacesUIUtils.ORGANIZER_QUERY_ANNO) && + !(as.getItemAnnotation(aItemId, PlacesUIUtils.ORGANIZER_QUERY_ANNO) in queries)) { + // Some extension annotated their roots with our query annotation, + // so we should not delete them. + return; + } + // removeItemAnnotation does not check if item exists, nor the anno, + // so this is safe to do. + as.removeItemAnnotation(aItemId, PlacesUIUtils.ORGANIZER_FOLDER_ANNO); + as.removeItemAnnotation(aItemId, PlacesUIUtils.ORGANIZER_QUERY_ANNO); + // This will throw if the annotation is an orphan. + bs.removeItem(aItemId); + } + catch(e) { /* orphan anno */ } + } + + // Returns true if item really exists, false otherwise. + function itemExists(aItemId) { + try { + bs.getItemIndex(aItemId); + return true; + } + catch(e) { + return false; + } + } + + // Get all items marked as being the left pane folder. + let items = as.getItemsWithAnnotation(this.ORGANIZER_FOLDER_ANNO); + if (items.length > 1) { + // Something went wrong, we cannot have more than one left pane folder, + // remove all left pane folders and continue. We will create a new one. + items.forEach(safeRemoveItem); + } + else if (items.length == 1 && items[0] != -1) { + leftPaneRoot = items[0]; + // Check that organizer left pane root is valid. + let version = as.getItemAnnotation(leftPaneRoot, this.ORGANIZER_FOLDER_ANNO); + if (version != this.ORGANIZER_LEFTPANE_VERSION || + !itemExists(leftPaneRoot)) { + // Invalid root, we must rebuild the left pane. + safeRemoveItem(leftPaneRoot); + leftPaneRoot = -1; + } + } + + if (leftPaneRoot != -1) { + // A valid left pane folder has been found. + // Build the leftPaneQueries Map. This is used to quickly access them, + // associating a mnemonic name to the real item ids. + delete this.leftPaneQueries; + this.leftPaneQueries = {}; + + let items = as.getItemsWithAnnotation(this.ORGANIZER_QUERY_ANNO); + // While looping through queries we will also check for their validity. + let queriesCount = 0; + for (let i = 0; i < items.length; i++) { + let queryName = as.getItemAnnotation(items[i], this.ORGANIZER_QUERY_ANNO); + + // Some extension did use our annotation to decorate their items + // with icons, so we should check only our elements, to avoid dataloss. + if (!(queryName in queries)) + continue; + + let query = queries[queryName]; + query.itemId = items[i]; + + if (!itemExists(query.itemId)) { + // Orphan annotation, bail out and create a new left pane root. + break; + } + + // Check that all queries have valid parents. + let parentId = bs.getFolderIdForItem(query.itemId); + if (items.indexOf(parentId) == -1 && parentId != leftPaneRoot) { + // The parent is not part of the left pane, bail out and create a new + // left pane root. + break; + } + + // Titles could have been corrupted or the user could have changed his + // locale. Check title and eventually fix it. + if (bs.getItemTitle(query.itemId) != query.title) + bs.setItemTitle(query.itemId, query.title); + if ("concreteId" in query) { + if (bs.getItemTitle(query.concreteId) != query.concreteTitle) + bs.setItemTitle(query.concreteId, query.concreteTitle); + } + + // Add the query to our cache. + this.leftPaneQueries[queryName] = query.itemId; + queriesCount++; + } + + if (queriesCount != EXPECTED_QUERY_COUNT) { + // Queries number is wrong, so the left pane must be corrupt. + // Note: we can't just remove the leftPaneRoot, because some query could + // have a bad parent, so we have to remove all items one by one. + items.forEach(safeRemoveItem); + safeRemoveItem(leftPaneRoot); + } + else { + // Everything is fine, return the current left pane folder. + delete this.leftPaneFolderId; + return this.leftPaneFolderId = leftPaneRoot; + } + } + + // Create a new left pane folder. + var callback = { + // Helper to create an organizer special query. + create_query: function(aQueryName, aParentId, aQueryUrl) { + let itemId = bs.insertBookmark(aParentId, + PlacesUtils._uri(aQueryUrl), + bs.DEFAULT_INDEX, + queries[aQueryName].title); + // Mark as special organizer query. + as.setItemAnnotation(itemId, PlacesUIUtils.ORGANIZER_QUERY_ANNO, aQueryName, + 0, as.EXPIRE_NEVER); + // We should never backup this, since it changes between profiles. + as.setItemAnnotation(itemId, PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO, 1, + 0, as.EXPIRE_NEVER); + // Add to the queries map. + PlacesUIUtils.leftPaneQueries[aQueryName] = itemId; + return itemId; + }, + + // Helper to create an organizer special folder. + create_folder: function(aFolderName, aParentId, aIsRoot) { + // Left Pane Root Folder. + let folderId = bs.createFolder(aParentId, + queries[aFolderName].title, + bs.DEFAULT_INDEX); + // We should never backup this, since it changes between profiles. + as.setItemAnnotation(folderId, PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO, 1, + 0, as.EXPIRE_NEVER); + + if (aIsRoot) { + // Mark as special left pane root. + as.setItemAnnotation(folderId, PlacesUIUtils.ORGANIZER_FOLDER_ANNO, + PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION, + 0, as.EXPIRE_NEVER); + } + else { + // Mark as special organizer folder. + as.setItemAnnotation(folderId, PlacesUIUtils.ORGANIZER_QUERY_ANNO, aFolderName, + 0, as.EXPIRE_NEVER); + PlacesUIUtils.leftPaneQueries[aFolderName] = folderId; + } + return folderId; + }, + + runBatched: function(aUserData) { + delete PlacesUIUtils.leftPaneQueries; + PlacesUIUtils.leftPaneQueries = { }; + + // Left Pane Root Folder. + leftPaneRoot = this.create_folder("PlacesRoot", bs.placesRoot, true); + + // History Query. + this.create_query("History", leftPaneRoot, + "place:type=" + + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY + + "&sort=" + + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING); + + // Downloads. + this.create_query("Downloads", leftPaneRoot, + "place:transition=" + + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD + + "&sort=" + + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING); + + // Tags Query. + this.create_query("Tags", leftPaneRoot, + "place:type=" + + Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY + + "&sort=" + + Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING); + + // All Bookmarks Folder. + allBookmarksId = this.create_folder("AllBookmarks", leftPaneRoot, false); + + // All Bookmarks->Bookmarks Toolbar Query. + this.create_query("BookmarksToolbar", allBookmarksId, + "place:folder=TOOLBAR"); + + // All Bookmarks->Bookmarks Menu Query. + this.create_query("BookmarksMenu", allBookmarksId, + "place:folder=BOOKMARKS_MENU"); + + // All Bookmarks->Unfiled Bookmarks Query. + this.create_query("UnfiledBookmarks", allBookmarksId, + "place:folder=UNFILED_BOOKMARKS"); + } + }; + bs.runInBatchMode(callback, null); + // Maybe: PlacesUtils.bookmarks.runInBatchMode(callback, null); ? + + delete this.leftPaneFolderId; + return this.leftPaneFolderId = leftPaneRoot; + }, + + /** + * Get the folder id for the organizer left-pane folder. + */ + get allBookmarksFolderId() { + // ensure the left-pane root is initialized; + this.leftPaneFolderId; + delete this.allBookmarksFolderId; + return this.allBookmarksFolderId = this.leftPaneQueries["AllBookmarks"]; + }, + + /** + * If an item is a left-pane query, returns the name of the query + * or an empty string if not. + * + * @param aItemId id of a container + * @returns the name of the query, or empty string if not a left-pane query + */ + getLeftPaneQueryNameFromId: function(aItemId) { + var queryName = ""; + // If the let pane hasn't been built, use the annotation service + // directly, to avoid building the left pane too early. + if (Object.getOwnPropertyDescriptor(this, "leftPaneFolderId").value === undefined) { + try { + queryName = PlacesUtils.annotations. + getItemAnnotation(aItemId, this.ORGANIZER_QUERY_ANNO); + } + catch (ex) { + // doesn't have the annotation + queryName = ""; + } + } + else { + // If the left pane has already been built, use the name->id map + // cached in PlacesUIUtils. + for (let [name, id] in Iterator(this.leftPaneQueries)) { + if (aItemId == id) + queryName = name; + } + } + return queryName; + }, + + /** + * Returns the passed URL with a #moz-resolution fragment + * for the specified dimensions and devicePixelRatio. + * + * @param aWindow + * A window from where we want to get the device + * pixel Ratio + * + * @param aURL + * The URL where we should add the fragment + * + * @param aWidth + * The target image width + * + * @param aHeight + * The target image height + * + * @return The URL with the fragment at the end + */ + getImageURLForResolution: + function(aWindow, aURL, aWidth, aHeight) { + return aURL; + } +}; + +XPCOMUtils.defineLazyServiceGetter(PlacesUIUtils, "RDF", + "@mozilla.org/rdf/rdf-service;1", + "nsIRDFService"); + +XPCOMUtils.defineLazyGetter(PlacesUIUtils, "localStore", function() { + return PlacesUIUtils.RDF.GetDataSource("rdf:local-store"); +}); + +XPCOMUtils.defineLazyGetter(PlacesUIUtils, "ellipsis", function() { + return Services.prefs.getComplexValue("intl.ellipsis", + Ci.nsIPrefLocalizedString).data; +}); + +XPCOMUtils.defineLazyServiceGetter(this, "URIFixup", + "@mozilla.org/docshell/urifixup;1", + "nsIURIFixup"); + +XPCOMUtils.defineLazyGetter(this, "bundle", function() { + const PLACES_STRING_BUNDLE_URI = + "chrome://browser/locale/places/places.properties"; + return Cc["@mozilla.org/intl/stringbundle;1"]. + getService(Ci.nsIStringBundleService). + createBundle(PLACES_STRING_BUNDLE_URI); +}); + +XPCOMUtils.defineLazyServiceGetter(this, "focusManager", + "@mozilla.org/focus-manager;1", + "nsIFocusManager"); + +/** + * This is a compatibility shim for old PUIU.ptm users. + * + * If you're looking for transactions and writing new code using them, directly + * use the transactions objects exported by the PlacesUtils.jsm module. + * + * This object will be removed once enough users are converted to the new API. + */ +XPCOMUtils.defineLazyGetter(PlacesUIUtils, "ptm", function() { + // Ensure PlacesUtils is imported in scope. + PlacesUtils; + + return { + aggregateTransactions: function(aName, aTransactions) + new PlacesAggregatedTransaction(aName, aTransactions), + + createFolder: function(aName, aContainer, aIndex, aAnnotations, + aChildItemsTransactions) + new PlacesCreateFolderTransaction(aName, aContainer, aIndex, aAnnotations, + aChildItemsTransactions), + + createItem: function(aURI, aContainer, aIndex, aTitle, aKeyword, + aAnnotations, aChildTransactions) + new PlacesCreateBookmarkTransaction(aURI, aContainer, aIndex, aTitle, + aKeyword, aAnnotations, + aChildTransactions), + + createSeparator: function(aContainer, aIndex) + new PlacesCreateSeparatorTransaction(aContainer, aIndex), + + createLivemark: function(aFeedURI, aSiteURI, aName, aContainer, aIndex, + aAnnotations) + new PlacesCreateLivemarkTransaction(aFeedURI, aSiteURI, aName, aContainer, + aIndex, aAnnotations), + + moveItem: function(aItemId, aNewContainer, aNewIndex) + new PlacesMoveItemTransaction(aItemId, aNewContainer, aNewIndex), + + removeItem: function(aItemId) + new PlacesRemoveItemTransaction(aItemId), + + editItemTitle: function(aItemId, aNewTitle) + new PlacesEditItemTitleTransaction(aItemId, aNewTitle), + + editBookmarkURI: function(aItemId, aNewURI) + new PlacesEditBookmarkURITransaction(aItemId, aNewURI), + + setItemAnnotation: function(aItemId, aAnnotationObject) + new PlacesSetItemAnnotationTransaction(aItemId, aAnnotationObject), + + setPageAnnotation: function(aURI, aAnnotationObject) + new PlacesSetPageAnnotationTransaction(aURI, aAnnotationObject), + + editBookmarkKeyword: function(aItemId, aNewKeyword) + new PlacesEditBookmarkKeywordTransaction(aItemId, aNewKeyword), + + editBookmarkPostData: function(aItemId, aPostData) + new PlacesEditBookmarkPostDataTransaction(aItemId, aPostData), + + editLivemarkSiteURI: function(aLivemarkId, aSiteURI) + new PlacesEditLivemarkSiteURITransaction(aLivemarkId, aSiteURI), + + editLivemarkFeedURI: function(aLivemarkId, aFeedURI) + new PlacesEditLivemarkFeedURITransaction(aLivemarkId, aFeedURI), + + editItemDateAdded: function(aItemId, aNewDateAdded) + new PlacesEditItemDateAddedTransaction(aItemId, aNewDateAdded), + + editItemLastModified: function(aItemId, aNewLastModified) + new PlacesEditItemLastModifiedTransaction(aItemId, aNewLastModified), + + sortFolderByName: function(aFolderId) + new PlacesSortFolderByNameTransaction(aFolderId), + + tagURI: function(aURI, aTags) + new PlacesTagURITransaction(aURI, aTags), + + untagURI: function(aURI, aTags) + new PlacesUntagURITransaction(aURI, aTags), + + /** + * Transaction for setting/unsetting Load-in-sidebar annotation. + * + * @param aBookmarkId + * id of the bookmark where to set Load-in-sidebar annotation. + * @param aLoadInSidebar + * boolean value. + * @returns nsITransaction object. + */ + setLoadInSidebar: function(aItemId, aLoadInSidebar) + { + let annoObj = { name: PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO, + type: Ci.nsIAnnotationService.TYPE_INT32, + flags: 0, + value: aLoadInSidebar, + expires: Ci.nsIAnnotationService.EXPIRE_NEVER }; + return new PlacesSetItemAnnotationTransaction(aItemId, annoObj); + }, + + /** + * Transaction for editing the description of a bookmark or a folder. + * + * @param aItemId + * id of the item to edit. + * @param aDescription + * new description. + * @returns nsITransaction object. + */ + editItemDescription: function(aItemId, aDescription) + { + let annoObj = { name: PlacesUIUtils.DESCRIPTION_ANNO, + type: Ci.nsIAnnotationService.TYPE_STRING, + flags: 0, + value: aDescription, + expires: Ci.nsIAnnotationService.EXPIRE_NEVER }; + return new PlacesSetItemAnnotationTransaction(aItemId, annoObj); + }, + + //////////////////////////////////////////////////////////////////////////// + //// nsITransactionManager forwarders. + + beginBatch: function() + PlacesUtils.transactionManager.beginBatch(null), + + endBatch: function() + PlacesUtils.transactionManager.endBatch(false), + + doTransaction: function(txn) + PlacesUtils.transactionManager.doTransaction(txn), + + undoTransaction: function() + PlacesUtils.transactionManager.undoTransaction(), + + redoTransaction: function() + PlacesUtils.transactionManager.redoTransaction(), + + get numberOfUndoItems() + PlacesUtils.transactionManager.numberOfUndoItems, + get numberOfRedoItems() + PlacesUtils.transactionManager.numberOfRedoItems, + get maxTransactionCount() + PlacesUtils.transactionManager.maxTransactionCount, + set maxTransactionCount(val) + PlacesUtils.transactionManager.maxTransactionCount = val, + + clear: function() + PlacesUtils.transactionManager.clear(), + + peekUndoStack: function() + PlacesUtils.transactionManager.peekUndoStack(), + + peekRedoStack: function() + PlacesUtils.transactionManager.peekRedoStack(), + + getUndoStack: function() + PlacesUtils.transactionManager.getUndoStack(), + + getRedoStack: function() + PlacesUtils.transactionManager.getRedoStack(), + + AddListener: function(aListener) + PlacesUtils.transactionManager.AddListener(aListener), + + RemoveListener: function(aListener) + PlacesUtils.transactionManager.RemoveListener(aListener) + } +}); diff --git a/browser/components/places/content/bookmarkProperties.js b/browser/components/places/content/bookmarkProperties.js new file mode 100644 index 000000000..7eae82715 --- /dev/null +++ b/browser/components/places/content/bookmarkProperties.js @@ -0,0 +1,675 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * The panel is initialized based on data given in the js object passed + * as window.arguments[0]. The object must have the following fields set: + * @ action (String). Possible values: + * - "add" - for adding a new item. + * @ type (String). Possible values: + * - "bookmark" + * @ loadBookmarkInSidebar - optional, the default state for the + * "Load this bookmark in the sidebar" field. + * - "folder" + * @ URIList (Array of nsIURI objects) - optional, list of uris to + * be bookmarked under the new folder. + * - "livemark" + * @ uri (nsIURI object) - optional, the default uri for the new item. + * The property is not used for the "folder with items" type. + * @ title (String) - optional, the default title for the new item. + * @ description (String) - optional, the default description for the new + * item. + * @ defaultInsertionPoint (InsertionPoint JS object) - optional, the + * default insertion point for the new item. + * @ keyword (String) - optional, the default keyword for the new item. + * @ postData (String) - optional, POST data to accompany the keyword. + * @ charSet (String) - optional, character-set to accompany the keyword. + * Notes: + * 1) If |uri| is set for a bookmark/livemark item and |title| isn't, + * the dialog will query the history tables for the title associated + * with the given uri. If the dialog is set to adding a folder with + * bookmark items under it (see URIList), a default static title is + * used ("[Folder Name]"). + * 2) The index field of the default insertion point is ignored if + * the folder picker is shown. + * - "edit" - for editing a bookmark item or a folder. + * @ type (String). Possible values: + * - "bookmark" + * @ itemId (Integer) - the id of the bookmark item. + * - "folder" (also applies to livemarks) + * @ itemId (Integer) - the id of the folder. + * @ hiddenRows (Strings array) - optional, list of rows to be hidden + * regardless of the item edited or added by the dialog. + * Possible values: + * - "title" + * - "location" + * - "description" + * - "keyword" + * - "tags" + * - "loadInSidebar" + * - "feedLocation" + * - "siteLocation" + * - "folderPicker" - hides both the tree and the menu. + * @ readOnly (Boolean) - optional, states if the panel should be read-only + * + * window.arguments[0].performed is set to true if any transaction has + * been performed by the dialog. + */ + +Components.utils.import('resource://gre/modules/XPCOMUtils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); + +const BOOKMARK_ITEM = 0; +const BOOKMARK_FOLDER = 1; +const LIVEMARK_CONTAINER = 2; + +const ACTION_EDIT = 0; +const ACTION_ADD = 1; + +var elementsHeight = new Map(); + +var BookmarkPropertiesPanel = { + + /** UI Text Strings */ + __strings: null, + get _strings() { + if (!this.__strings) { + this.__strings = document.getElementById("stringBundle"); + } + return this.__strings; + }, + + _action: null, + _itemType: null, + _itemId: -1, + _uri: null, + _loadInSidebar: false, + _title: "", + _description: "", + _URIs: [], + _keyword: "", + _postData: null, + _charSet: "", + _feedURI: null, + _siteURI: null, + + _defaultInsertionPoint: null, + _hiddenRows: [], + _batching: false, + _readOnly: false, + + /** + * This method returns the correct label for the dialog's "accept" + * button based on the variant of the dialog. + */ + _getAcceptLabel: function() { + if (this._action == ACTION_ADD) { + if (this._URIs.length) + return this._strings.getString("dialogAcceptLabelAddMulti"); + + if (this._itemType == LIVEMARK_CONTAINER) + return this._strings.getString("dialogAcceptLabelAddLivemark"); + + if (this._dummyItem || this._loadInSidebar) + return this._strings.getString("dialogAcceptLabelAddItem"); + + return this._strings.getString("dialogAcceptLabelSaveItem"); + } + return this._strings.getString("dialogAcceptLabelEdit"); + }, + + /** + * This method returns the correct title for the current variant + * of this dialog. + */ + _getDialogTitle: function() { + if (this._action == ACTION_ADD) { + if (this._itemType == BOOKMARK_ITEM) + return this._strings.getString("dialogTitleAddBookmark"); + if (this._itemType == LIVEMARK_CONTAINER) + return this._strings.getString("dialogTitleAddLivemark"); + + // add folder + NS_ASSERT(this._itemType == BOOKMARK_FOLDER, "Unknown item type"); + if (this._URIs.length) + return this._strings.getString("dialogTitleAddMulti"); + + return this._strings.getString("dialogTitleAddFolder"); + } + if (this._action == ACTION_EDIT) { + return this._strings.getFormattedString("dialogTitleEdit", [this._title]); + } + return ""; + }, + + /** + * Determines the initial data for the item edited or added by this dialog + */ + _determineItemInfo: function() { + var dialogInfo = window.arguments[0]; + this._action = dialogInfo.action == "add" ? ACTION_ADD : ACTION_EDIT; + this._hiddenRows = dialogInfo.hiddenRows ? dialogInfo.hiddenRows : []; + if (this._action == ACTION_ADD) { + NS_ASSERT("type" in dialogInfo, "missing type property for add action"); + + if ("title" in dialogInfo) + this._title = dialogInfo.title; + + if ("defaultInsertionPoint" in dialogInfo) { + this._defaultInsertionPoint = dialogInfo.defaultInsertionPoint; + } + else + this._defaultInsertionPoint = + new InsertionPoint(PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + Ci.nsITreeView.DROP_ON); + + switch (dialogInfo.type) { + case "bookmark": + this._itemType = BOOKMARK_ITEM; + if ("uri" in dialogInfo) { + NS_ASSERT(dialogInfo.uri instanceof Ci.nsIURI, + "uri property should be a uri object"); + this._uri = dialogInfo.uri; + if (typeof(this._title) != "string") { + this._title = this._getURITitleFromHistory(this._uri) || + this._uri.spec; + } + } + else { + this._uri = PlacesUtils._uri("about:blank"); + this._title = this._strings.getString("newBookmarkDefault"); + this._dummyItem = true; + } + + if ("loadBookmarkInSidebar" in dialogInfo) + this._loadInSidebar = dialogInfo.loadBookmarkInSidebar; + + if ("keyword" in dialogInfo) { + this._keyword = dialogInfo.keyword; + this._isAddKeywordDialog = true; + if ("postData" in dialogInfo) + this._postData = dialogInfo.postData; + if ("charSet" in dialogInfo) + this._charSet = dialogInfo.charSet; + } + break; + + case "folder": + this._itemType = BOOKMARK_FOLDER; + if (!this._title) { + if ("URIList" in dialogInfo) { + this._title = this._strings.getString("bookmarkAllTabsDefault"); + this._URIs = dialogInfo.URIList; + } + else + this._title = this._strings.getString("newFolderDefault"); + this._dummyItem = true; + } + break; + + case "livemark": + this._itemType = LIVEMARK_CONTAINER; + if ("feedURI" in dialogInfo) + this._feedURI = dialogInfo.feedURI; + if ("siteURI" in dialogInfo) + this._siteURI = dialogInfo.siteURI; + + if (!this._title) { + if (this._feedURI) { + this._title = this._getURITitleFromHistory(this._feedURI) || + this._feedURI.spec; + } + else + this._title = this._strings.getString("newLivemarkDefault"); + } + } + + if ("description" in dialogInfo) + this._description = dialogInfo.description; + } + else { // edit + NS_ASSERT("itemId" in dialogInfo); + this._itemId = dialogInfo.itemId; + this._title = PlacesUtils.bookmarks.getItemTitle(this._itemId); + this._readOnly = !!dialogInfo.readOnly; + + switch (dialogInfo.type) { + case "bookmark": + this._itemType = BOOKMARK_ITEM; + + this._uri = PlacesUtils.bookmarks.getBookmarkURI(this._itemId); + // keyword + this._keyword = PlacesUtils.bookmarks + .getKeywordForBookmark(this._itemId); + // Load In Sidebar + this._loadInSidebar = PlacesUtils.annotations + .itemHasAnnotation(this._itemId, + PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO); + break; + + case "folder": + this._itemType = BOOKMARK_FOLDER; + PlacesUtils.livemarks.getLivemark({ id: this._itemId }) + .then(aLivemark => { + this._itemType = LIVEMARK_CONTAINER; + this._feedURI = aLivemark.feedURI; + this._siteURI = aLivemark.siteURI; + this._fillEditProperties(); + + let acceptButton = document.documentElement.getButton("accept"); + acceptButton.disabled = !this._inputIsValid(); + + let newHeight = window.outerHeight + + this._element("descriptionField").boxObject.height; + window.resizeTo(window.outerWidth, newHeight); + }, () => undefined); + + break; + } + + // Description + if (PlacesUtils.annotations + .itemHasAnnotation(this._itemId, PlacesUIUtils.DESCRIPTION_ANNO)) { + this._description = PlacesUtils.annotations + .getItemAnnotation(this._itemId, + PlacesUIUtils.DESCRIPTION_ANNO); + } + } + }, + + /** + * This method returns the title string corresponding to a given URI. + * If none is available from the bookmark service (probably because + * the given URI doesn't appear in bookmarks or history), we synthesize + * a title from the first 100 characters of the URI. + * + * @param aURI + * nsIURI object for which we want the title + * + * @returns a title string + */ + _getURITitleFromHistory: function(aURI) { + NS_ASSERT(aURI instanceof Ci.nsIURI); + + // get the title from History + return PlacesUtils.history.getPageTitle(aURI); + }, + + /** + * This method should be called by the onload of the Bookmark Properties + * dialog to initialize the state of the panel. + */ + onDialogLoad: Task.async(function* () { + this._determineItemInfo(); + + document.title = this._getDialogTitle(); + var acceptButton = document.documentElement.getButton("accept"); + acceptButton.label = this._getAcceptLabel(); + + // Do not use sizeToContent, otherwise, due to bug 90276, the dialog will + // grow at every opening. + // Since elements can be uncollapsed asynchronously, we must observe their + // mutations and resize the dialog using a cached element size. + this._height = window.outerHeight; + this._mutationObserver = new MutationObserver(mutations => { + for (let mutation of mutations) { + let target = mutation.target; + let id = target.id; + if (!/^editBMPanel_.*(Row|Checkbox)$/.test(id)) + continue; + + let collapsed = target.getAttribute("collapsed") === "true"; + let wasCollapsed = mutation.oldValue === "true"; + if (collapsed == wasCollapsed) + continue; + + if (collapsed) { + this._height -= elementsHeight.get(id); + elementsHeight.delete(id); + } else { + elementsHeight.set(id, target.boxObject.height); + this._height += elementsHeight.get(id); + } + window.resizeTo(window.outerWidth, this._height); + } + }); + + this._mutationObserver.observe(document, + { subtree: true, + attributeOldValue: true, + attributeFilter: ["collapsed"] }); + + // Some controls are flexible and we want to update their cached size when + // the dialog is resized. + window.addEventListener("resize", this); + + this._beginBatch(); + + switch (this._action) { + case ACTION_EDIT: + this._fillEditProperties(); + acceptButton.disabled = this._readOnly; + break; + case ACTION_ADD: + yield this._fillAddProperties(); + // if this is an uri related dialog disable accept button until + // the user fills an uri value. + if (this._itemType == BOOKMARK_ITEM) + acceptButton.disabled = !this._inputIsValid(); + break; + } + + if (!this._readOnly) { + // Listen on uri fields to enable accept button if input is valid + if (this._itemType == BOOKMARK_ITEM) { + this._element("locationField") + .addEventListener("input", this, false); + if (this._isAddKeywordDialog) { + this._element("keywordField") + .addEventListener("input", this, false); + } + } + else if (this._itemType == LIVEMARK_CONTAINER) { + this._element("feedLocationField") + .addEventListener("input", this, false); + this._element("siteLocationField") + .addEventListener("input", this, false); + } + } + + // Ensure the Name Picker textbox is focused on load + var namePickerElem = document.getElementById('editBMPanel_namePicker'); + namePickerElem.focus(); + namePickerElem.select(); + }), + + // nsIDOMEventListener + handleEvent: function(aEvent) { + var target = aEvent.target; + switch (aEvent.type) { + case "input": + if (target.id == "editBMPanel_locationField" || + target.id == "editBMPanel_feedLocationField" || + target.id == "editBMPanel_siteLocationField" || + target.id == "editBMPanel_keywordField") { + // Check uri fields to enable accept button if input is valid + document.documentElement + .getButton("accept").disabled = !this._inputIsValid(); + } + break; + case "resize": + for (let [id, oldHeight] of elementsHeight) { + let newHeight = document.getElementById(id).boxObject.height; + this._height += - oldHeight + newHeight; + elementsHeight.set(id, newHeight); + } + break; + } + }, + + _beginBatch: function() { + if (this._batching) + return; + + PlacesUtils.transactionManager.beginBatch(null); + this._batching = true; + }, + + _endBatch: function() { + if (!this._batching) + return; + + PlacesUtils.transactionManager.endBatch(false); + this._batching = false; + }, + + _fillEditProperties: function() { + gEditItemOverlay.initPanel(this._itemId, + { hiddenRows: this._hiddenRows, + forceReadOnly: this._readOnly }); + }, + + _fillAddProperties: Task.async(function* () { + yield this._createNewItem(); + // Edit the new item + gEditItemOverlay.initPanel(this._itemId, + { hiddenRows: this._hiddenRows }); + // Empty location field if the uri is about:blank, this way inserting a new + // url will be easier for the user, Accept button will be automatically + // disabled by the input listener until the user fills the field. + var locationField = this._element("locationField"); + if (locationField.value == "about:blank") + locationField.value = ""; + }), + + // nsISupports + QueryInterface: function(aIID) { + if (aIID.equals(Ci.nsIDOMEventListener) || + aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_NOINTERFACE; + }, + + _element: function(aID) { + return document.getElementById("editBMPanel_" + aID); + }, + + onDialogUnload: function() { + // gEditItemOverlay does not exist anymore here, so don't rely on it. + this._mutationObserver.disconnect(); + delete this._mutationObserver; + + window.removeEventListener("resize", this); + + // Calling removeEventListener with arguments which do not identify any + // currently registered EventListener on the EventTarget has no effect. + this._element("locationField") + .removeEventListener("input", this, false); + this._element("feedLocationField") + .removeEventListener("input", this, false); + this._element("siteLocationField") + .removeEventListener("input", this, false); + }, + + onDialogAccept: function() { + // We must blur current focused element to save its changes correctly + document.commandDispatcher.focusedElement.blur(); + // The order here is important! We have to uninit the panel first, otherwise + // late changes could force it to commit more transactions. + gEditItemOverlay.uninitPanel(true); + this._endBatch(); + window.arguments[0].performed = true; + }, + + onDialogCancel: function() { + // The order here is important! We have to uninit the panel first, otherwise + // changes done as part of Undo may change the panel contents and by + // that force it to commit more transactions. + gEditItemOverlay.uninitPanel(true); + this._endBatch(); + PlacesUtils.transactionManager.undoTransaction(); + window.arguments[0].performed = false; + }, + + /** + * This method checks to see if the input fields are in a valid state. + * + * @returns true if the input is valid, false otherwise + */ + _inputIsValid: function() { + if (this._itemType == BOOKMARK_ITEM && + !this._containsValidURI("locationField")) + return false; + if (this._isAddKeywordDialog && !this._element("keywordField").value.length) + return false; + + return true; + }, + + /** + * Determines whether the XUL textbox with the given ID contains a + * string that can be converted into an nsIURI. + * + * @param aTextboxID + * the ID of the textbox element whose contents we'll test + * + * @returns true if the textbox contains a valid URI string, false otherwise + */ + _containsValidURI: function(aTextboxID) { + try { + var value = this._element(aTextboxID).value; + if (value) { + PlacesUIUtils.createFixedURI(value); + return true; + } + } catch (e) { } + return false; + }, + + /** + * [New Item Mode] Get the insertion point details for the new item, given + * dialog state and opening arguments. + * + * The container-identifier and insertion-index are returned separately in + * the form of [containerIdentifier, insertionIndex] + */ + _getInsertionPointDetails: function() { + var containerId = this._defaultInsertionPoint.itemId; + var indexInContainer = this._defaultInsertionPoint.index; + + return [containerId, indexInContainer]; + }, + + /** + * Returns a transaction for creating a new bookmark item representing the + * various fields and opening arguments of the dialog. + */ + _getCreateNewBookmarkTransaction: + function(aContainer, aIndex) { + var annotations = []; + var childTransactions = []; + + if (this._description) { + let annoObj = { name : PlacesUIUtils.DESCRIPTION_ANNO, + type : Ci.nsIAnnotationService.TYPE_STRING, + flags : 0, + value : this._description, + expires: Ci.nsIAnnotationService.EXPIRE_NEVER }; + let editItemTxn = new PlacesSetItemAnnotationTransaction(-1, annoObj); + childTransactions.push(editItemTxn); + } + + if (this._loadInSidebar) { + let annoObj = { name : PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO, + value : true }; + let setLoadTxn = new PlacesSetItemAnnotationTransaction(-1, annoObj); + childTransactions.push(setLoadTxn); + } + + if (this._postData) { + let postDataTxn = new PlacesEditBookmarkPostDataTransaction(-1, this._postData); + childTransactions.push(postDataTxn); + } + + //XXX TODO: this should be in a transaction! + if (this._charSet && !PrivateBrowsingUtils.isWindowPrivate(window)) + PlacesUtils.setCharsetForURI(this._uri, this._charSet); + + let createTxn = new PlacesCreateBookmarkTransaction(this._uri, + aContainer, + aIndex, + this._title, + this._keyword, + annotations, + childTransactions); + + return new PlacesAggregatedTransaction(this._getDialogTitle(), + [createTxn]); + }, + + /** + * Returns a childItems-transactions array representing the URIList with + * which the dialog has been opened. + */ + _getTransactionsForURIList: function() { + var transactions = []; + for (var i = 0; i < this._URIs.length; ++i) { + var uri = this._URIs[i]; + var title = this._getURITitleFromHistory(uri); + var createTxn = new PlacesCreateBookmarkTransaction(uri, -1, + PlacesUtils.bookmarks.DEFAULT_INDEX, + title); + transactions.push(createTxn); + } + return transactions; + }, + + /** + * Returns a transaction for creating a new folder item representing the + * various fields and opening arguments of the dialog. + */ + _getCreateNewFolderTransaction: + function(aContainer, aIndex) { + var annotations = []; + var childItemsTransactions; + if (this._URIs.length) + childItemsTransactions = this._getTransactionsForURIList(); + + if (this._description) + annotations.push(this._getDescriptionAnnotation(this._description)); + + return new PlacesCreateFolderTransaction(this._title, aContainer, + aIndex, annotations, + childItemsTransactions); + }, + + /** + * Returns a transaction for creating a new live-bookmark item representing + * the various fields and opening arguments of the dialog. + */ + _getCreateNewLivemarkTransaction: + function(aContainer, aIndex) { + return new PlacesCreateLivemarkTransaction(this._feedURI, this._siteURI, + this._title, + aContainer, aIndex); + }, + + /** + * Dialog-accept code-path for creating a new item (any type) + */ + _createNewItem: Task.async(function* () { + var [container, index] = this._getInsertionPointDetails(); + var txn; + + switch (this._itemType) { + case BOOKMARK_FOLDER: + txn = this._getCreateNewFolderTransaction(container, index); + break; + case LIVEMARK_CONTAINER: + txn = this._getCreateNewLivemarkTransaction(container, index); + break; + default: // BOOKMARK_ITEM + txn = this._getCreateNewBookmarkTransaction(container, index); + } + + PlacesUtils.transactionManager.doTransaction(txn); + // This is a temporary hack until we use PlacesTransactions.jsm + if (txn._promise) { + yield txn._promise; + } + + let folderGuid = yield PlacesUtils.promiseItemGuid(container); + let bm = yield PlacesUtils.bookmarks.fetch({ + parentGuid: folderGuid, + index: index + }); + this._itemId = yield PlacesUtils.promiseItemId(bm.guid); + }) +}; diff --git a/browser/components/places/content/bookmarkProperties.xul b/browser/components/places/content/bookmarkProperties.xul new file mode 100644 index 000000000..2c04f8b05 --- /dev/null +++ b/browser/components/places/content/bookmarkProperties.xul @@ -0,0 +1,43 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/"?> +<?xml-stylesheet href="chrome://browser/skin/places/editBookmarkOverlay.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/places.css"?> +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> + +<?xul-overlay href="chrome://browser/content/places/placesOverlay.xul"?> +<?xul-overlay href="chrome://browser/content/places/editBookmarkOverlay.xul"?> + +<!DOCTYPE dialog [ + <!ENTITY % editBookmarkOverlayDTD SYSTEM "chrome://browser/locale/places/editBookmarkOverlay.dtd"> + %editBookmarkOverlayDTD; +]> + +<dialog id="bookmarkproperties" + buttons="accept, cancel" + buttoniconaccept="save" + ondialogaccept="BookmarkPropertiesPanel.onDialogAccept();" + ondialogcancel="BookmarkPropertiesPanel.onDialogCancel();" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="BookmarkPropertiesPanel.onDialogLoad();" + onunload="BookmarkPropertiesPanel.onDialogUnload();" + style="min-width: 30em;" + persist="screenX screenY width"> + + <stringbundleset id="stringbundleset"> + <stringbundle id="stringBundle" + src="chrome://browser/locale/places/bookmarkProperties.properties"/> + </stringbundleset> + + <script type="application/javascript" + src="chrome://browser/content/places/editBookmarkOverlay.js"/> + <script type="application/javascript" + src="chrome://browser/content/places/bookmarkProperties.js"/> + +<vbox id="editBookmarkPanelContent"/> + +</dialog> diff --git a/browser/components/places/content/bookmarksPanel.js b/browser/components/places/content/bookmarksPanel.js new file mode 100644 index 000000000..c964bd094 --- /dev/null +++ b/browser/components/places/content/bookmarksPanel.js @@ -0,0 +1,25 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +function init() { + document.getElementById("bookmarks-view").place = + "place:queryType=1&folder=" + window.top.PlacesUIUtils.allBookmarksFolderId; +} + +function searchBookmarks(aSearchString) { + var tree = document.getElementById('bookmarks-view'); + if (!aSearchString) + tree.place = tree.place; + else + tree.applyFilter(aSearchString, + [PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.toolbarFolderId]); +} + +window.addEventListener("SidebarFocused", + function() + document.getElementById("search-box").focus(), + false); diff --git a/browser/components/places/content/bookmarksPanel.xul b/browser/components/places/content/bookmarksPanel.xul new file mode 100644 index 000000000..45744bb05 --- /dev/null +++ b/browser/components/places/content/bookmarksPanel.xul @@ -0,0 +1,55 @@ +<?xml version="1.0"?> <!-- -*- Mode: SGML; indent-tabs-mode: nil; -*- --> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/places.css"?> +<?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?> +<?xul-overlay href="chrome://browser/content/places/placesOverlay.xul"?> + +<!DOCTYPE page SYSTEM "chrome://browser/locale/places/places.dtd"> + +<page id="bookmarksPanel" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="init();" + onunload="SidebarUtils.setMouseoverURL('');"> + + <script type="application/javascript" + src="chrome://browser/content/bookmarks/sidebarUtils.js"/> + <script type="application/javascript" + src="chrome://browser/content/bookmarks/bookmarksPanel.js"/> + + <commandset id="placesCommands"/> + <commandset id="editMenuCommands"/> + <keyset id="placesCommandKeys"/> + <menupopup id="placesContext"/> + + <!-- Bookmarks and history tooltip --> + <tooltip id="bhTooltip"/> + + <hbox id="sidebar-search-container" align="center"> + <label id="sidebar-search-label" + value="&search.label;" accesskey="&search.accesskey;" control="search-box"/> + <textbox id="search-box" flex="1" type="search" class="compact" + aria-controls="bookmarks-view" + oncommand="searchBookmarks(this.value);"/> + </hbox> + + <tree id="bookmarks-view" class="sidebar-placesTree" type="places" + flex="1" + hidecolumnpicker="true" + context="placesContext" + onkeypress="SidebarUtils.handleTreeKeyPress(event);" + onclick="SidebarUtils.handleTreeClick(this, event, true);" + onmousemove="SidebarUtils.handleTreeMouseMove(event);" + onmouseout="SidebarUtils.setMouseoverURL('');"> + <treecols> + <treecol id="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren id="bookmarks-view-children" view="bookmarks-view" + class="sidebar-placesTreechildren" flex="1" tooltip="bhTooltip"/> + </tree> +</page> diff --git a/browser/components/places/content/browserPlacesViews.js b/browser/components/places/content/browserPlacesViews.js new file mode 100644 index 000000000..a80e5f817 --- /dev/null +++ b/browser/components/places/content/browserPlacesViews.js @@ -0,0 +1,1726 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +/** + * The base view implements everything that's common to the toolbar and + * menu views. + */ +function PlacesViewBase(aPlace) { + this.place = aPlace; + this._controller = new PlacesController(this); + this._viewElt.controllers.appendController(this._controller); +} + +PlacesViewBase.prototype = { + // The xul element that holds the entire view. + _viewElt: null, + get viewElt() this._viewElt, + + get associatedElement() this._viewElt, + + get controllers() this._viewElt.controllers, + + // The xul element that represents the root container. + _rootElt: null, + + // Set to true for views that are represented by native widgets (i.e. + // the native mac menu). + _nativeView: false, + + QueryInterface: XPCOMUtils.generateQI( + [Components.interfaces.nsINavHistoryResultObserver, + Components.interfaces.nsISupportsWeakReference]), + + _place: "", + get place() this._place, + set place(val) { + this._place = val; + + let history = PlacesUtils.history; + let queries = { }, options = { }; + history.queryStringToQueries(val, queries, { }, options); + if (!queries.value.length) + queries.value = [history.getNewQuery()]; + + let result = history.executeQueries(queries.value, queries.value.length, + options.value); + result.addObserver(this, false); + return val; + }, + + _result: null, + get result() this._result, + set result(val) { + if (this._result == val) + return val; + + if (this._result) { + this._result.removeObserver(this); + this._resultNode.containerOpen = false; + } + + if (this._rootElt.localName == "menupopup") + this._rootElt._built = false; + + this._result = val; + if (val) { + this._resultNode = val.root; + this._rootElt._placesNode = this._resultNode; + this._domNodes = new Map(); + this._domNodes.set(this._resultNode, this._rootElt); + + // This calls _rebuild through invalidateContainer. + this._resultNode.containerOpen = true; + } + else { + this._resultNode = null; + delete this._domNodes; + } + + return val; + }, + + /** + * Gets the DOM node used for the given places node. + * + * @param aPlacesNode + * a places result node. + * @throws if there is no DOM node set for aPlacesNode. + */ + _getDOMNodeForPlacesNode: + function(aPlacesNode) { + let node = this._domNodes.get(aPlacesNode, null); + if (!node) { + throw new Error("No DOM node set for aPlacesNode.\nnode.type: " + + aPlacesNode.type + ". node.parent: " + aPlacesNode); + } + return node; + }, + + get controller() this._controller, + + get selType() "single", + selectItems: function() { }, + selectAll: function() { }, + + get selectedNode() { + if (this._contextMenuShown) { + let anchor = this._contextMenuShown.triggerNode; + if (!anchor) + return null; + + if (anchor._placesNode) + return this._rootElt == anchor ? null : anchor._placesNode; + + anchor = anchor.parentNode; + return this._rootElt == anchor ? null : (anchor._placesNode || null); + } + return null; + }, + + get hasSelection() this.selectedNode != null, + + get selectedNodes() { + let selectedNode = this.selectedNode; + return selectedNode ? [selectedNode] : []; + }, + + get removableSelectionRanges() { + // On static content the current selectedNode would be the selection's + // parent node. We don't want to allow removing a node when the + // selection is not explicit. + if (document.popupNode && + (document.popupNode == "menupopup" || !document.popupNode._placesNode)) + return []; + + return [this.selectedNodes]; + }, + + get draggableSelection() [this._draggedElt], + + get insertionPoint() { + // There is no insertion point for history queries, so bail out now and + // save a lot of work when updating commands. + let resultNode = this._resultNode; + if (PlacesUtils.nodeIsQuery(resultNode) && + PlacesUtils.asQuery(resultNode).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) + return null; + + // By default, the insertion point is at the top level, at the end. + let index = PlacesUtils.bookmarks.DEFAULT_INDEX; + let container = this._resultNode; + let orientation = Ci.nsITreeView.DROP_BEFORE; + let isTag = false; + + let selectedNode = this.selectedNode; + if (selectedNode) { + let popup = document.popupNode; + if (!popup._placesNode || popup._placesNode == this._resultNode || + popup._placesNode.itemId == -1) { + // If a static menuitem is selected, or if the root node is selected, + // the insertion point is inside the folder, at the end. + container = selectedNode; + orientation = Ci.nsITreeView.DROP_ON; + } + else { + // In all other cases the insertion point is before that node. + container = selectedNode.parent; + index = container.getChildIndex(selectedNode); + isTag = PlacesUtils.nodeIsTagQuery(container); + } + } + + if (PlacesControllerDragHelper.disallowInsertion(container)) + return null; + + return new InsertionPoint(PlacesUtils.getConcreteItemId(container), + index, orientation, isTag); + }, + + buildContextMenu: function(aPopup) { + this._contextMenuShown = aPopup; + window.updateCommands("places"); + return this.controller.buildContextMenu(aPopup); + }, + + destroyContextMenu: function(aPopup) { + this._contextMenuShown = null; + }, + + _cleanPopup: function(aPopup, aDelay) { + // Remove Places nodes from the popup. + let child = aPopup._startMarker; + while (child.nextSibling != aPopup._endMarker) { + let sibling = child.nextSibling; + if (sibling._placesNode && !aDelay) { + aPopup.removeChild(sibling); + } + else if (sibling._placesNode && aDelay) { + // HACK (bug 733419): the popups originating from the OS X native + // menubar don't live-update while open, thus we don't clean it + // until the next popupshowing, to avoid zombie menuitems. + if (!aPopup._delayedRemovals) + aPopup._delayedRemovals = []; + aPopup._delayedRemovals.push(sibling); + child = child.nextSibling; + } + else { + child = child.nextSibling; + } + } + }, + + _rebuildPopup: function(aPopup) { + let resultNode = aPopup._placesNode; + if (!resultNode.containerOpen) + return; + + if (this.controller.hasCachedLivemarkInfo(resultNode)) { + this._setEmptyPopupStatus(aPopup, false); + aPopup._built = true; + this._populateLivemarkPopup(aPopup); + return; + } + + this._cleanPopup(aPopup); + + let cc = resultNode.childCount; + if (cc > 0) { + this._setEmptyPopupStatus(aPopup, false); + + for (let i = 0; i < cc; ++i) { + let child = resultNode.getChild(i); + this._insertNewItemToPopup(child, aPopup, null); + } + } + else { + this._setEmptyPopupStatus(aPopup, true); + } + aPopup._built = true; + }, + + _removeChild: function(aChild) { + // If document.popupNode pointed to this child, null it out, + // otherwise controller's command-updating may rely on the removed + // item still being "selected". + if (document.popupNode == aChild) + document.popupNode = null; + + aChild.parentNode.removeChild(aChild); + }, + + _setEmptyPopupStatus: + function(aPopup, aEmpty) { + if (!aPopup._emptyMenuitem) { + let label = PlacesUIUtils.getString("bookmarksMenuEmptyFolder"); + aPopup._emptyMenuitem = document.createElement("menuitem"); + aPopup._emptyMenuitem.setAttribute("label", label); + aPopup._emptyMenuitem.setAttribute("disabled", true); + } + + if (aEmpty) { + aPopup.setAttribute("emptyplacesresult", "true"); + // Don't add the menuitem if there is static content. + if (!aPopup._startMarker.previousSibling && + !aPopup._endMarker.nextSibling) + aPopup.insertBefore(aPopup._emptyMenuitem, aPopup._endMarker); + } + else { + aPopup.removeAttribute("emptyplacesresult"); + try { + aPopup.removeChild(aPopup._emptyMenuitem); + } catch (ex) {} + } + }, + + _createMenuItemForPlacesNode: + function(aPlacesNode) { + this._domNodes.delete(aPlacesNode); + + let element; + let type = aPlacesNode.type; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + element = document.createElement("menuseparator"); + } + else { + let itemId = aPlacesNode.itemId; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI) { + element = document.createElement("menuitem"); + element.className = "menuitem-iconic bookmark-item menuitem-with-favicon"; + element.setAttribute("scheme", + PlacesUIUtils.guessUrlSchemeForUI(aPlacesNode.uri)); + } + else if (PlacesUtils.containerTypes.indexOf(type) != -1) { + element = document.createElement("menu"); + element.setAttribute("container", "true"); + + if (aPlacesNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) { + element.setAttribute("query", "true"); + if (PlacesUtils.nodeIsTagQuery(aPlacesNode)) + element.setAttribute("tagContainer", "true"); + else if (PlacesUtils.nodeIsDay(aPlacesNode)) + element.setAttribute("dayContainer", "true"); + else if (PlacesUtils.nodeIsHost(aPlacesNode)) + element.setAttribute("hostContainer", "true"); + } + else if (itemId != -1) { + PlacesUtils.livemarks.getLivemark({ id: itemId }) + .then(aLivemark => { + element.setAttribute("livemark", "true"); + this.controller.cacheLivemarkInfo(aPlacesNode, aLivemark); + }, () => undefined); + } + + let popup = document.createElement("menupopup"); + popup._placesNode = PlacesUtils.asContainer(aPlacesNode); + + if (!this._nativeView) { + popup.setAttribute("placespopup", "true"); + } + + element.appendChild(popup); + element.className = "menu-iconic bookmark-item"; + + this._domNodes.set(aPlacesNode, popup); + } + else + throw "Unexpected node"; + + element.setAttribute("label", PlacesUIUtils.getBestTitle(aPlacesNode)); + + let icon = aPlacesNode.icon; + if (icon) + element.setAttribute("image", + PlacesUIUtils.getImageURLForResolution(window, icon)); + } + + element._placesNode = aPlacesNode; + if (!this._domNodes.has(aPlacesNode)) + this._domNodes.set(aPlacesNode, element); + + return element; + }, + + _insertNewItemToPopup: + function(aNewChild, aPopup, aBefore) { + let element = this._createMenuItemForPlacesNode(aNewChild); + let before = aBefore || aPopup._endMarker; + aPopup.insertBefore(element, before); + return element; + }, + + _setLivemarkSiteURIMenuItem: + function(aPopup) { + let livemarkInfo = this.controller.getCachedLivemarkInfo(aPopup._placesNode); + let siteUrl = livemarkInfo && livemarkInfo.siteURI ? + livemarkInfo.siteURI.spec : null; + if (!siteUrl && aPopup._siteURIMenuitem) { + aPopup.removeChild(aPopup._siteURIMenuitem); + aPopup._siteURIMenuitem = null; + aPopup.removeChild(aPopup._siteURIMenuseparator); + aPopup._siteURIMenuseparator = null; + } + else if (siteUrl && !aPopup._siteURIMenuitem) { + // Add "Open (Feed Name)" menuitem. + aPopup._siteURIMenuitem = document.createElement("menuitem"); + aPopup._siteURIMenuitem.className = "openlivemarksite-menuitem"; + aPopup._siteURIMenuitem.setAttribute("targetURI", siteUrl); + aPopup._siteURIMenuitem.setAttribute("oncommand", + "openUILink(this.getAttribute('targetURI'), event);"); + + // If a user middle-clicks this item we serve the oncommand event. + // We are using checkForMiddleClick because of Bug 246720. + // Note: stopPropagation is needed to avoid serving middle-click + // with BT_onClick that would open all items in tabs. + aPopup._siteURIMenuitem.setAttribute("onclick", + "checkForMiddleClick(this, event); event.stopPropagation();"); + let label = + PlacesUIUtils.getFormattedString("menuOpenLivemarkOrigin.label", + [aPopup.parentNode.getAttribute("label")]) + aPopup._siteURIMenuitem.setAttribute("label", label); + aPopup.insertBefore(aPopup._siteURIMenuitem, aPopup._startMarker); + + aPopup._siteURIMenuseparator = document.createElement("menuseparator"); + aPopup.insertBefore(aPopup._siteURIMenuseparator, aPopup._startMarker); + } + }, + + /** + * Add, update or remove the livemark status menuitem. + * @param aPopup + * The livemark container popup + * @param aStatus + * The livemark status + */ + _setLivemarkStatusMenuItem: + function(aPopup, aStatus) { + let statusMenuitem = aPopup._statusMenuitem; + if (!statusMenuitem) { + // Create the status menuitem and cache it in the popup object. + statusMenuitem = document.createElement("menuitem"); + statusMenuitem.className = "livemarkstatus-menuitem"; + statusMenuitem.setAttribute("disabled", true); + aPopup._statusMenuitem = statusMenuitem; + } + + if (aStatus == Ci.mozILivemark.STATUS_LOADING || + aStatus == Ci.mozILivemark.STATUS_FAILED) { + // Status has changed, update the cached status menuitem. + let stringId = aStatus == Ci.mozILivemark.STATUS_LOADING ? + "bookmarksLivemarkLoading" : "bookmarksLivemarkFailed"; + statusMenuitem.setAttribute("label", PlacesUIUtils.getString(stringId)); + if (aPopup._startMarker.nextSibling != statusMenuitem) + aPopup.insertBefore(statusMenuitem, aPopup._startMarker.nextSibling); + } + else { + // The livemark has finished loading. + if (aPopup._statusMenuitem.parentNode == aPopup) + aPopup.removeChild(aPopup._statusMenuitem); + } + }, + + toggleCutNode: function(aPlacesNode, aValue) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // We may get the popup for menus, but we need the menu itself. + if (elt.localName == "menupopup") + elt = elt.parentNode; + if (aValue) + elt.setAttribute("cutting", "true"); + else + elt.removeAttribute("cutting"); + }, + + nodeURIChanged: function(aPlacesNode, aURIString) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + elt.setAttribute("scheme", PlacesUIUtils.guessUrlSchemeForUI(aURIString)); + }, + + nodeIconChanged: function(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // There's no UI representation for the root node, thus there's nothing to + // be done when the icon changes. + if (elt == this._rootElt) + return; + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + let icon = aPlacesNode.icon; + if (!icon) + elt.removeAttribute("image"); + else if (icon != elt.getAttribute("image")) + elt.setAttribute("image", + PlacesUIUtils.getImageURLForResolution(window, icon)); + }, + + nodeAnnotationChanged: + function(aPlacesNode, aAnno) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // All livemarks have a feedURI, so use it as our indicator of a livemark + // being modified. + if (aAnno == PlacesUtils.LMANNO_FEEDURI) { + let menu = elt.parentNode; + if (!menu.hasAttribute("livemark")) { + menu.setAttribute("livemark", "true"); + } + + PlacesUtils.livemarks.getLivemark({ id: aPlacesNode.itemId }) + .then(aLivemark => { + // Controller will use this to build the meta data for the node. + this.controller.cacheLivemarkInfo(aPlacesNode, aLivemark); + this.invalidateContainer(aPlacesNode); + }, () => undefined); + } + }, + + nodeTitleChanged: + function(aPlacesNode, aNewTitle) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // There's no UI representation for the root node, thus there's + // nothing to be done when the title changes. + if (elt == this._rootElt) + return; + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + if (!aNewTitle && elt.localName != "toolbarbutton") { + // Many users consider toolbars as shortcuts containers, so explicitly + // allow empty labels on toolbarbuttons. For any other element try to be + // smarter, guessing a title from the uri. + elt.setAttribute("label", PlacesUIUtils.getBestTitle(aPlacesNode)); + } + else { + elt.setAttribute("label", aNewTitle); + } + }, + + nodeRemoved: + function(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + if (parentElt._built) { + parentElt.removeChild(elt); + + // Figure out if we need to show the "<Empty>" menu-item. + // TODO Bug 517701: This doesn't seem to handle the case of an empty + // root. + if (parentElt._startMarker.nextSibling == parentElt._endMarker) + this._setEmptyPopupStatus(parentElt, true); + } + }, + + nodeHistoryDetailsChanged: + function(aPlacesNode, aTime, aCount) { + if (aPlacesNode.parent && + this.controller.hasCachedLivemarkInfo(aPlacesNode.parent)) { + // Find the node in the parent. + let popup = this._getDOMNodeForPlacesNode(aPlacesNode.parent); + for (let child = popup._startMarker.nextSibling; + child != popup._endMarker; + child = child.nextSibling) { + if (child._placesNode && child._placesNode.uri == aPlacesNode.uri) { + if (aCount) + child.setAttribute("visited", "true"); + else + child.removeAttribute("visited"); + break; + } + } + } + }, + + nodeTagsChanged: function() { }, + nodeDateAddedChanged: function() { }, + nodeLastModifiedChanged: function() { }, + nodeKeywordChanged: function() { }, + sortingChanged: function() { }, + batching: function() { }, + + nodeInserted: + function(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (!parentElt._built) + return; + + let index = Array.indexOf(parentElt.childNodes, parentElt._startMarker) + + aIndex + 1; + this._insertNewItemToPopup(aPlacesNode, parentElt, + parentElt.childNodes[index]); + this._setEmptyPopupStatus(parentElt, false); + }, + + nodeMoved: + function(aPlacesNode, + aOldParentPlacesNode, aOldIndex, + aNewParentPlacesNode, aNewIndex) { + // Note: the current implementation of moveItem does not actually + // use this notification when the item in question is moved from one + // folder to another. Instead, it calls nodeRemoved and nodeInserted + // for the two folders. Thus, we can assume old-parent == new-parent. + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + // If our root node is a folder, it might be moved. There's nothing + // we need to do in that case. + if (elt == this._rootElt) + return; + + let parentElt = this._getDOMNodeForPlacesNode(aNewParentPlacesNode); + if (parentElt._built) { + // Move the node. + parentElt.removeChild(elt); + let index = Array.indexOf(parentElt.childNodes, parentElt._startMarker) + + aNewIndex + 1; + parentElt.insertBefore(elt, parentElt.childNodes[index]); + } + }, + + containerStateChanged: + function(aPlacesNode, aOldState, aNewState) { + if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED || + aNewState == Ci.nsINavHistoryContainerResultNode.STATE_CLOSED) { + this.invalidateContainer(aPlacesNode); + + if (PlacesUtils.nodeIsFolder(aPlacesNode)) { + let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions; + if (queryOptions.excludeItems) { + return; + } + + PlacesUtils.livemarks.getLivemark({ id: aPlacesNode.itemId }) + .then(aLivemark => { + let shouldInvalidate = + !this.controller.hasCachedLivemarkInfo(aPlacesNode); + this.controller.cacheLivemarkInfo(aPlacesNode, aLivemark); + if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED) { + aLivemark.registerForUpdates(aPlacesNode, this); + // Prioritize the current livemark. + aLivemark.reload(); + PlacesUtils.livemarks.reloadLivemarks(); + if (shouldInvalidate) + this.invalidateContainer(aPlacesNode); + } + else { + aLivemark.unregisterForUpdates(aPlacesNode); + } + }, () => undefined); + } + } + }, + + _populateLivemarkPopup: function(aPopup) + { + this._setLivemarkSiteURIMenuItem(aPopup); + // Show the loading status only if there are no entries yet. + if (aPopup._startMarker.nextSibling == aPopup._endMarker) + this._setLivemarkStatusMenuItem(aPopup, Ci.mozILivemark.STATUS_LOADING); + + PlacesUtils.livemarks.getLivemark({ id: aPopup._placesNode.itemId }) + .then(aLivemark => { + let placesNode = aPopup._placesNode; + if (!placesNode.containerOpen) + return; + + if (aLivemark.status != Ci.mozILivemark.STATUS_LOADING) + this._setLivemarkStatusMenuItem(aPopup, aLivemark.status); + this._cleanPopup(aPopup, + this._nativeView && aPopup.parentNode.hasAttribute("open")); + + let children = aLivemark.getNodesForContainer(placesNode); + for (let i = 0; i < children.length; i++) { + let child = children[i]; + this.nodeInserted(placesNode, child, i); + if (child.accessCount) + this._getDOMNodeForPlacesNode(child).setAttribute("visited", true); + else + this._getDOMNodeForPlacesNode(child).removeAttribute("visited"); + } + }, Components.utils.reportError); + }, + + invalidateContainer: function(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + elt._built = false; + + // If the menupopup is open we should live-update it. + if (elt.parentNode.open) + this._rebuildPopup(elt); + }, + + uninit: function() { + if (this._result) { + this._result.removeObserver(this); + this._resultNode.containerOpen = false; + this._resultNode = null; + this._result = null; + } + + if (this._controller) { + this._controller.terminate(); + // Removing the controller will fail if it is already no longer there. + // This can happen if the view element was removed/reinserted without + // our knowledge. There is no way to check for that having happened + // without the possibility of an exception. :-( + try { + this._viewElt.controllers.removeController(this._controller); + } catch (ex) { + } finally { + this._controller = null; + } + } + + delete this._viewElt._placesView; + }, + + get isRTL() { + if ("_isRTL" in this) + return this._isRTL; + + return this._isRTL = document.defaultView + .getComputedStyle(this.viewElt, "") + .direction == "rtl"; + }, + + get ownerWindow() window, + + /** + * Adds an "Open All in Tabs" menuitem to the bottom of the popup. + * @param aPopup + * a Places popup. + */ + _mayAddCommandsItems: function(aPopup) { + // The command items are never added to the root popup. + if (aPopup == this._rootElt) + return; + + let hasMultipleURIs = false; + + // Check if the popup contains at least 2 menuitems with places nodes. + // We don't currently support opening multiple uri nodes when they are not + // populated by the result. + if (aPopup._placesNode.childCount > 0) { + let currentChild = aPopup.firstChild; + let numURINodes = 0; + while (currentChild) { + if (currentChild.localName == "menuitem" && currentChild._placesNode) { + if (++numURINodes == 2) + break; + } + currentChild = currentChild.nextSibling; + } + hasMultipleURIs = numURINodes > 1; + } + + let isLiveMark = false; + if (this.controller.hasCachedLivemarkInfo(aPopup._placesNode)) { + hasMultipleURIs = true; + isLiveMark = true; + } + + if (!hasMultipleURIs) { + // We don't have to show any option. + if (aPopup._endOptOpenAllInTabs) { + aPopup.removeChild(aPopup._endOptOpenAllInTabs); + aPopup._endOptOpenAllInTabs = null; + + aPopup.removeChild(aPopup._endOptSeparator); + aPopup._endOptSeparator = null; + } + } + else if (!aPopup._endOptOpenAllInTabs) { + // Create a separator before options. + aPopup._endOptSeparator = document.createElement("menuseparator"); + aPopup._endOptSeparator.className = "bookmarks-actions-menuseparator"; + aPopup.appendChild(aPopup._endOptSeparator); + + // Add the "Open All in Tabs" menuitem. + aPopup._endOptOpenAllInTabs = document.createElement("menuitem"); + aPopup._endOptOpenAllInTabs.className = "openintabs-menuitem"; + if (isLiveMark) { + aPopup._endOptOpenAllInTabs.setAttribute("oncommand", + "PlacesUIUtils.openLiveMarkNodesInTabs(this.parentNode._placesNode, event, " + + "PlacesUIUtils.getViewForNode(this));"); + } else { + aPopup._endOptOpenAllInTabs.setAttribute("oncommand", + "PlacesUIUtils.openContainerNodeInTabs(this.parentNode._placesNode, event, " + + "PlacesUIUtils.getViewForNode(this));"); + } + aPopup._endOptOpenAllInTabs.setAttribute("onclick", + "checkForMiddleClick(this, event); event.stopPropagation();"); + aPopup._endOptOpenAllInTabs.setAttribute("label", + gNavigatorBundle.getString("menuOpenAllInTabs.label")); + aPopup.appendChild(aPopup._endOptOpenAllInTabs); + } + }, + + _ensureMarkers: function(aPopup) { + if (aPopup._startMarker) + return; + + // _startMarker is an hidden menuseparator that lives before places nodes. + aPopup._startMarker = document.createElement("menuseparator"); + aPopup._startMarker.hidden = true; + aPopup.insertBefore(aPopup._startMarker, aPopup.firstChild); + + // _endMarker is an hidden menuseparator that lives after places nodes. + aPopup._endMarker = document.createElement("menuseparator"); + aPopup._endMarker.hidden = true; + aPopup.appendChild(aPopup._endMarker); + + // Move the markers to the right position. + let firstNonStaticNodeFound = false; + for (let i = 0; i < aPopup.childNodes.length; i++) { + let child = aPopup.childNodes[i]; + // Menus that have static content at the end, but are initially empty, + // use a special "builder" attribute to figure out where to start + // inserting places nodes. + if (child.getAttribute("builder") == "end") { + aPopup.insertBefore(aPopup._endMarker, child); + break; + } + + if (child._placesNode && !firstNonStaticNodeFound) { + firstNonStaticNodeFound = true; + aPopup.insertBefore(aPopup._startMarker, child); + } + } + if (!firstNonStaticNodeFound) { + aPopup.insertBefore(aPopup._startMarker, aPopup._endMarker); + } + }, + + _onPopupShowing: function(aEvent) { + // Avoid handling popupshowing of inner views. + let popup = aEvent.originalTarget; + + this._ensureMarkers(popup); + + // Remove any delayed element, see _cleanPopup for details. + if ("_delayedRemovals" in popup) { + while (popup._delayedRemovals.length > 0) { + popup.removeChild(popup._delayedRemovals.shift()); + } + } + + if (popup._placesNode && PlacesUIUtils.getViewForNode(popup) == this) { + if (!popup._placesNode.containerOpen) + popup._placesNode.containerOpen = true; + if (!popup._built) + this._rebuildPopup(popup); + + this._mayAddCommandsItems(popup); + } + }, + + _addEventListeners: + function(aObject, aEventNames, aCapturing) { + for (let i = 0; i < aEventNames.length; i++) { + aObject.addEventListener(aEventNames[i], this, aCapturing); + } + }, + + _removeEventListeners: + function(aObject, aEventNames, aCapturing) { + for (let i = 0; i < aEventNames.length; i++) { + aObject.removeEventListener(aEventNames[i], this, aCapturing); + } + }, +}; + +function PlacesToolbar(aPlace) { + let startTime = Date.now(); + // Add some smart getters for our elements. + let thisView = this; + [ + ["_viewElt", "PlacesToolbar"], + ["_rootElt", "PlacesToolbarItems"], + ["_dropIndicator", "PlacesToolbarDropIndicator"], + ["_chevron", "PlacesChevron"], + ["_chevronPopup", "PlacesChevronPopup"] + ].forEach(function(elementGlobal) { + let [name, id] = elementGlobal; + thisView.__defineGetter__(name, function() { + let element = document.getElementById(id); + if (!element) + return null; + + delete thisView[name]; + return thisView[name] = element; + }); + }); + + this._viewElt._placesView = this; + + this._addEventListeners(this._viewElt, this._cbEvents, false); + this._addEventListeners(this._rootElt, ["popupshowing", "popuphidden"], true); + this._addEventListeners(this._rootElt, ["overflow", "underflow"], true); + this._addEventListeners(window, ["resize", "unload"], false); + + // If personal-bookmarks has been dragged to the tabs toolbar, + // we have to track addition and removals of tabs, to properly + // recalculate the available space for bookmarks. + // TODO (bug 734730): Use a performant mutation listener when available. + if (this._viewElt.parentNode.parentNode == document.getElementById("TabsToolbar")) { + this._addEventListeners(gBrowser.tabContainer, ["TabOpen", "TabClose"], false); + } + + PlacesViewBase.call(this, aPlace); +} + +PlacesToolbar.prototype = { + __proto__: PlacesViewBase.prototype, + + _cbEvents: ["dragstart", "dragover", "dragexit", "dragend", "drop", + "mousemove", "mouseover", "mouseout"], + + QueryInterface: function(aIID) { + if (aIID.equals(Ci.nsIDOMEventListener) || + aIID.equals(Ci.nsITimerCallback)) + return this; + + return PlacesViewBase.prototype.QueryInterface.apply(this, arguments); + }, + + uninit: function() { + this._removeEventListeners(this._viewElt, this._cbEvents, false); + this._removeEventListeners(this._rootElt, ["popupshowing", "popuphidden"], + true); + this._removeEventListeners(this._rootElt, ["overflow", "underflow"], true); + this._removeEventListeners(window, ["resize", "unload"], false); + this._removeEventListeners(gBrowser.tabContainer, ["TabOpen", "TabClose"], false); + + PlacesViewBase.prototype.uninit.apply(this, arguments); + }, + + _openedMenuButton: null, + _allowPopupShowing: true, + + _rebuild: function() { + // Clear out references to existing nodes, since they will be removed + // and re-added. + if (this._overFolder.elt) + this._clearOverFolder(); + + this._openedMenuButton = null; + while (this._rootElt.hasChildNodes()) { + this._rootElt.removeChild(this._rootElt.firstChild); + } + + let cc = this._resultNode.childCount; + for (let i = 0; i < cc; ++i) { + this._insertNewItem(this._resultNode.getChild(i), null); + } + + if (this._chevronPopup.hasAttribute("type")) { + // Chevron has already been initialized, but since we are forcing + // a rebuild of the toolbar, it has to be rebuilt. + // Otherwise, it will be initialized when the toolbar overflows. + this._chevronPopup.place = this.place; + } + }, + + _insertNewItem: + function(aChild, aBefore) { + this._domNodes.delete(aChild); + + let type = aChild.type; + let button; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + button = document.createElement("toolbarseparator"); + } + else { + button = document.createElement("toolbarbutton"); + button.className = "bookmark-item"; + button.setAttribute("label", aChild.title || ""); + let icon = aChild.icon; + if (icon) + button.setAttribute("image", + PlacesUIUtils.getImageURLForResolution(window, icon)); + + if (PlacesUtils.containerTypes.indexOf(type) != -1) { + button.setAttribute("type", "menu"); + button.setAttribute("container", "true"); + + if (PlacesUtils.nodeIsQuery(aChild)) { + button.setAttribute("query", "true"); + if (PlacesUtils.nodeIsTagQuery(aChild)) + button.setAttribute("tagContainer", "true"); + } + else if (PlacesUtils.nodeIsFolder(aChild)) { + PlacesUtils.livemarks.getLivemark({ id: aChild.itemId }) + .then(aLivemark => { + button.setAttribute("livemark", "true"); + this.controller.cacheLivemarkInfo(aChild, aLivemark); + }, () => undefined); + } + + let popup = document.createElement("menupopup"); + popup.setAttribute("placespopup", "true"); + button.appendChild(popup); + popup._placesNode = PlacesUtils.asContainer(aChild); + popup.setAttribute("context", "placesContext"); + + this._domNodes.set(aChild, popup); + } + else if (PlacesUtils.nodeIsURI(aChild)) { + button.setAttribute("scheme", + PlacesUIUtils.guessUrlSchemeForUI(aChild.uri)); + } + } + + button._placesNode = aChild; + if (!this._domNodes.has(aChild)) + this._domNodes.set(aChild, button); + + if (aBefore) + this._rootElt.insertBefore(button, aBefore); + else + this._rootElt.appendChild(button); + }, + + _updateChevronPopupNodesVisibility: + function() { + for (let i = 0, node = this._chevronPopup._startMarker.nextSibling; + node != this._chevronPopup._endMarker; + i++, node = node.nextSibling) { + node.hidden = this._rootElt.childNodes[i].style.visibility != "hidden"; + } + }, + + _onChevronPopupShowing: + function(aEvent) { + // Handle popupshowing only for the chevron popup, not for nested ones. + if (aEvent.target != this._chevronPopup) + return; + + if (!this._chevron._placesView) + this._chevron._placesView = new PlacesMenu(aEvent, this.place); + + this._updateChevronPopupNodesVisibility(); + }, + + handleEvent: function(aEvent) { + switch (aEvent.type) { + case "unload": + this.uninit(); + break; + case "resize": + // This handler updates nodes visibility in both the toolbar + // and the chevron popup when a window resize does not change + // the overflow status of the toolbar. + this.updateChevron(); + break; + case "overflow": + if (aEvent.target != aEvent.currentTarget) + return; + + // Ignore purely vertical overflows. + if (aEvent.detail == 0) + return; + + // Attach the popup binding to the chevron popup if it has not yet + // been initialized. + if (!this._chevronPopup.hasAttribute("type")) { + this._chevronPopup.setAttribute("place", this.place); + this._chevronPopup.setAttribute("type", "places"); + } + this._chevron.collapsed = false; + this.updateChevron(); + break; + case "underflow": + if (aEvent.target != aEvent.currentTarget) + return; + + // Ignore purely vertical underflows. + if (aEvent.detail == 0) + return; + + this.updateChevron(); + this._chevron.collapsed = true; + break; + case "TabOpen": + case "TabClose": + this.updateChevron(); + break; + case "dragstart": + this._onDragStart(aEvent); + break; + case "dragover": + this._onDragOver(aEvent); + break; + case "dragexit": + this._onDragExit(aEvent); + break; + case "dragend": + this._onDragEnd(aEvent); + break; + case "drop": + this._onDrop(aEvent); + break; + case "mouseover": + this._onMouseOver(aEvent); + break; + case "mousemove": + this._onMouseMove(aEvent); + break; + case "mouseout": + this._onMouseOut(aEvent); + break; + case "popupshowing": + this._onPopupShowing(aEvent); + break; + case "popuphidden": + this._onPopupHidden(aEvent); + break; + default: + throw "Trying to handle unexpected event."; + } + }, + + updateChevron: function() { + // If the chevron is collapsed there's nothing to update. + if (this._chevron.collapsed) + return; + + // Update the chevron on a timer. This will avoid repeated work when + // lot of changes happen in a small timeframe. + if (this._updateChevronTimer) + this._updateChevronTimer.cancel(); + + this._updateChevronTimer = this._setTimer(100); + }, + + _updateChevronTimerCallback: function() { + let scrollRect = this._rootElt.getBoundingClientRect(); + let childOverflowed = false; + for (let i = 0; i < this._rootElt.childNodes.length; i++) { + let child = this._rootElt.childNodes[i]; + // Once a child overflows, all the next ones will. + if (!childOverflowed) { + let childRect = child.getBoundingClientRect(); + childOverflowed = this.isRTL ? (childRect.left < scrollRect.left) + : (childRect.right > scrollRect.right); + + } + child.style.visibility = childOverflowed ? "hidden" : "visible"; + } + + // We rebuild the chevron on popupShowing, so if it is open + // we must update it. + if (this._chevron.open) + this._updateChevronPopupNodesVisibility(); + }, + + nodeInserted: + function(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (parentElt == this._rootElt) { + let children = this._rootElt.childNodes; + this._insertNewItem(aPlacesNode, + aIndex < children.length ? children[aIndex] : null); + this.updateChevron(); + return; + } + + PlacesViewBase.prototype.nodeInserted.apply(this, arguments); + }, + + nodeRemoved: + function(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + if (parentElt == this._rootElt) { + this._removeChild(elt); + this.updateChevron(); + return; + } + + PlacesViewBase.prototype.nodeRemoved.apply(this, arguments); + }, + + nodeMoved: + function(aPlacesNode, + aOldParentPlacesNode, aOldIndex, + aNewParentPlacesNode, aNewIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aNewParentPlacesNode); + if (parentElt == this._rootElt) { + // Container is on the toolbar. + + // Move the element. + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + this._removeChild(elt); + this._rootElt.insertBefore(elt, this._rootElt.childNodes[aNewIndex]); + + // The chevron view may get nodeMoved after the toolbar. In such a case, + // we should ensure (by manually swapping menuitems) that the actual nodes + // are in the final position before updateChevron tries to updates their + // visibility, or the chevron may go out of sync. + // Luckily updateChevron runs on a timer, so, by the time it updates + // nodes, the menu has already handled the notification. + + this.updateChevron(); + return; + } + + PlacesViewBase.prototype.nodeMoved.apply(this, arguments); + }, + + nodeAnnotationChanged: + function(aPlacesNode, aAnno) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + if (elt == this._rootElt) + return; + + // We're notified for the menupopup, not the containing toolbarbutton. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + if (elt.parentNode == this._rootElt) { + // Node is on the toolbar. + + // All livemarks have a feedURI, so use it as our indicator. + if (aAnno == PlacesUtils.LMANNO_FEEDURI) { + elt.setAttribute("livemark", true); + + PlacesUtils.livemarks.getLivemark({ id: aPlacesNode.itemId }) + .then(aLivemark => { + this.controller.cacheLivemarkInfo(aPlacesNode, aLivemark); + this.invalidateContainer(aPlacesNode); + }, Components.utils.reportError); + } + } + else { + // Node is in a submenu. + PlacesViewBase.prototype.nodeAnnotationChanged.apply(this, arguments); + } + }, + + nodeTitleChanged: function(aPlacesNode, aNewTitle) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // There's no UI representation for the root node, thus there's + // nothing to be done when the title changes. + if (elt == this._rootElt) + return; + + PlacesViewBase.prototype.nodeTitleChanged.apply(this, arguments); + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + if (elt.parentNode == this._rootElt) { + // Node is on the toolbar + this.updateChevron(); + } + }, + + invalidateContainer: function(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + if (elt == this._rootElt) { + // Container is the toolbar itself. + this._rebuild(); + return; + } + + PlacesViewBase.prototype.invalidateContainer.apply(this, arguments); + }, + + _overFolder: { elt: null, + openTimer: null, + hoverTime: 350, + closeTimer: null }, + + _clearOverFolder: function() { + // The mouse is no longer dragging over the stored menubutton. + // Close the menubutton, clear out drag styles, and clear all + // timers for opening/closing it. + if (this._overFolder.elt && this._overFolder.elt.lastChild) { + if (!this._overFolder.elt.lastChild.hasAttribute("dragover")) { + this._overFolder.elt.lastChild.hidePopup(); + } + this._overFolder.elt.removeAttribute("dragover"); + this._overFolder.elt = null; + } + if (this._overFolder.openTimer) { + this._overFolder.openTimer.cancel(); + this._overFolder.openTimer = null; + } + if (this._overFolder.closeTimer) { + this._overFolder.closeTimer.cancel(); + this._overFolder.closeTimer = null; + } + }, + + /** + * This function returns information about where to drop when dragging over + * the toolbar. The returned object has the following properties: + * - ip: the insertion point for the bookmarks service. + * - beforeIndex: child index to drop before, for the drop indicator. + * - folderElt: the folder to drop into, if applicable. + */ + _getDropPoint: function(aEvent) { + let result = this.result; + if (!PlacesUtils.nodeIsFolder(this._resultNode)) + return null; + + let dropPoint = { ip: null, beforeIndex: null, folderElt: null }; + let elt = aEvent.target; + if (elt._placesNode && elt != this._rootElt && + elt.localName != "menupopup") { + let eltRect = elt.getBoundingClientRect(); + let eltIndex = Array.indexOf(this._rootElt.childNodes, elt); + if (PlacesUtils.nodeIsFolder(elt._placesNode) && + !PlacesUIUtils.isContentsReadOnly(elt._placesNode)) { + // This is a folder. + // If we are in the middle of it, drop inside it. + // Otherwise, drop before it, with regards to RTL mode. + let threshold = eltRect.width * 0.25; + if (this.isRTL ? (aEvent.clientX > eltRect.right - threshold) + : (aEvent.clientX < eltRect.left + threshold)) { + // Drop before this folder. + dropPoint.ip = + new InsertionPoint(PlacesUtils.getConcreteItemId(this._resultNode), + eltIndex, Ci.nsITreeView.DROP_BEFORE); + dropPoint.beforeIndex = eltIndex; + } + else if (this.isRTL ? (aEvent.clientX > eltRect.left + threshold) + : (aEvent.clientX < eltRect.right - threshold)) { + // Drop inside this folder. + dropPoint.ip = + new InsertionPoint(PlacesUtils.getConcreteItemId(elt._placesNode), + -1, Ci.nsITreeView.DROP_ON, + PlacesUtils.nodeIsTagQuery(elt._placesNode)); + dropPoint.beforeIndex = eltIndex; + dropPoint.folderElt = elt; + } + else { + // Drop after this folder. + let beforeIndex = + (eltIndex == this._rootElt.childNodes.length - 1) ? + -1 : eltIndex + 1; + + dropPoint.ip = + new InsertionPoint(PlacesUtils.getConcreteItemId(this._resultNode), + beforeIndex, Ci.nsITreeView.DROP_BEFORE); + dropPoint.beforeIndex = beforeIndex; + } + } + else { + // This is a non-folder node or a read-only folder. + // Drop before it with regards to RTL mode. + let threshold = eltRect.width * 0.5; + if (this.isRTL ? (aEvent.clientX > eltRect.left + threshold) + : (aEvent.clientX < eltRect.left + threshold)) { + // Drop before this bookmark. + dropPoint.ip = + new InsertionPoint(PlacesUtils.getConcreteItemId(this._resultNode), + eltIndex, Ci.nsITreeView.DROP_BEFORE); + dropPoint.beforeIndex = eltIndex; + } + else { + // Drop after this bookmark. + let beforeIndex = + eltIndex == this._rootElt.childNodes.length - 1 ? + -1 : eltIndex + 1; + dropPoint.ip = + new InsertionPoint(PlacesUtils.getConcreteItemId(this._resultNode), + beforeIndex, Ci.nsITreeView.DROP_BEFORE); + dropPoint.beforeIndex = beforeIndex; + } + } + } + else { + // We are most likely dragging on the empty area of the + // toolbar, we should drop after the last node. + dropPoint.ip = + new InsertionPoint(PlacesUtils.getConcreteItemId(this._resultNode), + -1, Ci.nsITreeView.DROP_BEFORE); + dropPoint.beforeIndex = -1; + } + + return dropPoint; + }, + + _setTimer: function(aTime) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(this, aTime, timer.TYPE_ONE_SHOT); + return timer; + }, + + notify: function(aTimer) { + if (aTimer == this._updateChevronTimer) { + this._updateChevronTimer = null; + this._updateChevronTimerCallback(); + } + + // * Timer to turn off indicator bar. + else if (aTimer == this._ibTimer) { + this._dropIndicator.collapsed = true; + this._ibTimer = null; + } + + // * Timer to open a menubutton that's being dragged over. + else if (aTimer == this._overFolder.openTimer) { + // Set the autoopen attribute on the folder's menupopup so that + // the menu will automatically close when the mouse drags off of it. + this._overFolder.elt.lastChild.setAttribute("autoopened", "true"); + this._overFolder.elt.open = true; + this._overFolder.openTimer = null; + } + + // * Timer to close a menubutton that's been dragged off of. + else if (aTimer == this._overFolder.closeTimer) { + // Close the menubutton if we are not dragging over it or one of + // its children. The autoopened attribute will let the menu know to + // close later if the menu is still being dragged over. + let currentPlacesNode = PlacesControllerDragHelper.currentDropTarget; + let inHierarchy = false; + while (currentPlacesNode) { + if (currentPlacesNode == this._rootElt) { + inHierarchy = true; + break; + } + currentPlacesNode = currentPlacesNode.parentNode; + } + // The _clearOverFolder() function will close the menu for + // _overFolder.elt. So null it out if we don't want to close it. + if (inHierarchy) + this._overFolder.elt = null; + + // Clear out the folder and all associated timers. + this._clearOverFolder(); + } + }, + + _onMouseOver: function(aEvent) { + let button = aEvent.target; + if (button.parentNode == this._rootElt && button._placesNode && + PlacesUtils.nodeIsURI(button._placesNode)) + window.XULBrowserWindow.setOverLink(aEvent.target._placesNode.uri, null); + }, + + _onMouseOut: function(aEvent) { + window.XULBrowserWindow.setOverLink("", null); + }, + + _cleanupDragDetails: function() { + // Called on dragend and drop. + PlacesControllerDragHelper.currentDropTarget = null; + this._draggedElt = null; + if (this._ibTimer) + this._ibTimer.cancel(); + + this._dropIndicator.collapsed = true; + }, + + _onDragStart: function(aEvent) { + // Sub menus have their own d&d handlers. + let draggedElt = aEvent.target; + if (draggedElt.parentNode != this._rootElt || !draggedElt._placesNode) + return; + + if (draggedElt.localName == "toolbarbutton" && + draggedElt.getAttribute("type") == "menu") { + // If the drag gesture on a container is toward down we open instead + // of dragging. + let translateY = this._cachedMouseMoveEvent.clientY - aEvent.clientY; + let translateX = this._cachedMouseMoveEvent.clientX - aEvent.clientX; + if ((translateY) >= Math.abs(translateX/2)) { + // Don't start the drag. + aEvent.preventDefault(); + // Open the menu. + draggedElt.open = true; + return; + } + + // If the menu is open, close it. + if (draggedElt.open) { + draggedElt.lastChild.hidePopup(); + draggedElt.open = false; + } + } + + // Activate the view and cache the dragged element. + this._draggedElt = draggedElt._placesNode; + this._rootElt.focus(); + + this._controller.setDataTransfer(aEvent); + aEvent.stopPropagation(); + }, + + _onDragOver: function(aEvent) { + // Cache the dataTransfer + PlacesControllerDragHelper.currentDropTarget = aEvent.target; + let dt = aEvent.dataTransfer; + + let dropPoint = this._getDropPoint(aEvent); + if (!dropPoint || !dropPoint.ip || + !PlacesControllerDragHelper.canDrop(dropPoint.ip, dt)) { + this._dropIndicator.collapsed = true; + aEvent.stopPropagation(); + return; + } + + if (this._ibTimer) { + this._ibTimer.cancel(); + this._ibTimer = null; + } + + if (dropPoint.folderElt || aEvent.originalTarget == this._chevron) { + // Dropping over a menubutton or chevron button. + // Set styles and timer to open relative menupopup. + let overElt = dropPoint.folderElt || this._chevron; + if (this._overFolder.elt != overElt) { + this._clearOverFolder(); + this._overFolder.elt = overElt; + this._overFolder.openTimer = this._setTimer(this._overFolder.hoverTime); + } + if (!this._overFolder.elt.hasAttribute("dragover")) + this._overFolder.elt.setAttribute("dragover", "true"); + + this._dropIndicator.collapsed = true; + } + else { + // Dragging over a normal toolbarbutton, + // show indicator bar and move it to the appropriate drop point. + let ind = this._dropIndicator; + let halfInd = ind.clientWidth / 2; + let translateX; + if (this.isRTL) { + halfInd = Math.ceil(halfInd); + translateX = 0 - this._rootElt.getBoundingClientRect().right - halfInd; + if (this._rootElt.firstChild) { + if (dropPoint.beforeIndex == -1) + translateX += this._rootElt.lastChild.getBoundingClientRect().left; + else { + translateX += this._rootElt.childNodes[dropPoint.beforeIndex] + .getBoundingClientRect().right; + } + } + } + else { + halfInd = Math.floor(halfInd); + translateX = 0 - this._rootElt.getBoundingClientRect().left + + halfInd; + if (this._rootElt.firstChild) { + if (dropPoint.beforeIndex == -1) + translateX += this._rootElt.lastChild.getBoundingClientRect().right; + else { + translateX += this._rootElt.childNodes[dropPoint.beforeIndex] + .getBoundingClientRect().left; + } + } + } + + ind.style.transform = "translate(" + Math.round(translateX) + "px)"; + ind.style.MozMarginStart = (-ind.clientWidth) + "px"; + ind.collapsed = false; + + // Clear out old folder information. + this._clearOverFolder(); + } + + aEvent.preventDefault(); + aEvent.stopPropagation(); + }, + + _onDrop: function(aEvent) { + PlacesControllerDragHelper.currentDropTarget = aEvent.target; + + let dropPoint = this._getDropPoint(aEvent); + if (dropPoint && dropPoint.ip) { + PlacesControllerDragHelper.onDrop(dropPoint.ip, aEvent.dataTransfer) + aEvent.preventDefault(); + } + + this._cleanupDragDetails(); + aEvent.stopPropagation(); + }, + + _onDragExit: function(aEvent) { + PlacesControllerDragHelper.currentDropTarget = null; + + // Set timer to turn off indicator bar (if we turn it off + // here, dragenter might be called immediately after, creating + // flicker). + if (this._ibTimer) + this._ibTimer.cancel(); + this._ibTimer = this._setTimer(10); + + // If we hovered over a folder, close it now. + if (this._overFolder.elt) + this._overFolder.closeTimer = this._setTimer(this._overFolder.hoverTime); + }, + + _onDragEnd: function(aEvent) { + this._cleanupDragDetails(); + }, + + _onPopupShowing: function(aEvent) { + if (!this._allowPopupShowing) { + this._allowPopupShowing = true; + aEvent.preventDefault(); + return; + } + + let parent = aEvent.target.parentNode; + if (parent.localName == "toolbarbutton") + this._openedMenuButton = parent; + + PlacesViewBase.prototype._onPopupShowing.apply(this, arguments); + }, + + _onPopupHidden: function(aEvent) { + let popup = aEvent.target; + let placesNode = popup._placesNode; + // Avoid handling popuphidden of inner views + if (placesNode && PlacesUIUtils.getViewForNode(popup) == this) { + // UI performance: folder queries are cheap, keep the resultnode open + // so we don't rebuild its contents whenever the popup is reopened. + // Though, we want to always close feed containers so their expiration + // status will be checked at next opening. + if (!PlacesUtils.nodeIsFolder(placesNode) || + this.controller.hasCachedLivemarkInfo(placesNode)) { + placesNode.containerOpen = false; + } + } + + let parent = popup.parentNode; + if (parent.localName == "toolbarbutton") { + this._openedMenuButton = null; + // Clear the dragover attribute if present, if we are dragging into a + // folder in the hierachy of current opened popup we don't clear + // this attribute on clearOverFolder. See Notify for closeTimer. + if (parent.hasAttribute("dragover")) + parent.removeAttribute("dragover"); + } + }, + + _onMouseMove: function(aEvent) { + // Used in dragStart to prevent dragging folders when dragging down. + this._cachedMouseMoveEvent = aEvent; + + if (this._openedMenuButton == null || + PlacesControllerDragHelper.getSession()) + return; + + let target = aEvent.originalTarget; + if (this._openedMenuButton != target && + target.localName == "toolbarbutton" && + target.type == "menu") { + this._openedMenuButton.open = false; + target.open = true; + } + } +}; + +/** + * View for Places menus. This object should be created during the first + * popupshowing that's dispatched on the menu. + */ +function PlacesMenu(aPopupShowingEvent, aPlace) { + this._rootElt = aPopupShowingEvent.target; // <menupopup> + this._viewElt = this._rootElt.parentNode; // <menu> + this._viewElt._placesView = this; + this._addEventListeners(this._rootElt, ["popupshowing", "popuphidden"], true); + this._addEventListeners(window, ["unload"], false); + + PlacesViewBase.call(this, aPlace); + this._onPopupShowing(aPopupShowingEvent); +} + +PlacesMenu.prototype = { + __proto__: PlacesViewBase.prototype, + + QueryInterface: function(aIID) { + if (aIID.equals(Ci.nsIDOMEventListener)) + return this; + + return PlacesViewBase.prototype.QueryInterface.apply(this, arguments); + }, + + _removeChild: function(aChild) { + PlacesViewBase.prototype._removeChild.apply(this, arguments); + }, + + uninit: function() { + this._removeEventListeners(this._rootElt, ["popupshowing", "popuphidden"], + true); + this._removeEventListeners(window, ["unload"], false); + + PlacesViewBase.prototype.uninit.apply(this, arguments); + }, + + handleEvent: function(aEvent) { + switch (aEvent.type) { + case "unload": + this.uninit(); + break; + case "popupshowing": + this._onPopupShowing(aEvent); + break; + case "popuphidden": + this._onPopupHidden(aEvent); + break; + } + }, + + _onPopupHidden: function(aEvent) { + // Avoid handling popuphidden of inner views. + let popup = aEvent.originalTarget; + let placesNode = popup._placesNode; + if (!placesNode || PlacesUIUtils.getViewForNode(popup) != this) + return; + + // UI performance: folder queries are cheap, keep the resultnode open + // so we don't rebuild its contents whenever the popup is reopened. + // Though, we want to always close feed containers so their expiration + // status will be checked at next opening. + if (!PlacesUtils.nodeIsFolder(placesNode) || + this.controller.hasCachedLivemarkInfo(placesNode)) + placesNode.containerOpen = false; + + // The autoopened attribute is set for folders which have been + // automatically opened when dragged over. Turn off this attribute + // when the folder closes because it is no longer applicable. + popup.removeAttribute("autoopened"); + popup.removeAttribute("dragstart"); + } +}; + diff --git a/browser/components/places/content/controller.js b/browser/components/places/content/controller.js new file mode 100644 index 000000000..33312330f --- /dev/null +++ b/browser/components/places/content/controller.js @@ -0,0 +1,1895 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/ForgetAboutSite.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +// XXXmano: we should move most/all of these constants to PlacesUtils +const ORGANIZER_ROOT_BOOKMARKS = "place:folder=BOOKMARKS_MENU&excludeItems=1&queryType=1"; + +// No change to the view, preserve current selection +const RELOAD_ACTION_NOTHING = 0; +// Inserting items new to the view, select the inserted rows +const RELOAD_ACTION_INSERT = 1; +// Removing items from the view, select the first item after the last selected +const RELOAD_ACTION_REMOVE = 2; +// Moving items within a view, don't treat the dropped items as additional +// rows. +const RELOAD_ACTION_MOVE = 3; + +// When removing a bunch of pages we split them in chunks to give some breath +// to the main-thread. +const REMOVE_PAGES_CHUNKLEN = 300; + +/** + * Represents an insertion point within a container where we can insert + * items. + * @param aItemId + * The identifier of the parent container + * @param aIndex + * The index within the container where we should insert + * @param aOrientation + * The orientation of the insertion. NOTE: the adjustments to the + * insertion point to accommodate the orientation should be done by + * the person who constructs the IP, not the user. The orientation + * is provided for informational purposes only! + * @param [optional] aIsTag + * Indicates if parent container is a tag + * @param [optional] aDropNearItemId + * When defined we will calculate index based on this itemId + * @constructor + */ +function InsertionPoint(aItemId, aIndex, aOrientation, aIsTag, + aDropNearItemId) { + this.itemId = aItemId; + this._index = aIndex; + this.orientation = aOrientation; + this.isTag = aIsTag; + this.dropNearItemId = aDropNearItemId; +} + +InsertionPoint.prototype = { + set index(val) { + return this._index = val; + }, + + get index() { + if (this.dropNearItemId > 0) { + // If dropNearItemId is set up we must calculate the real index of + // the item near which we will drop. + var index = PlacesUtils.bookmarks.getItemIndex(this.dropNearItemId); + return this.orientation == Ci.nsITreeView.DROP_BEFORE ? index : index + 1; + } + return this._index; + } +}; + +/** + * Places Controller + */ + +function PlacesController(aView) { + this._view = aView; + XPCOMUtils.defineLazyServiceGetter(this, "clipboard", + "@mozilla.org/widget/clipboard;1", + "nsIClipboard"); + XPCOMUtils.defineLazyGetter(this, "profileName", function() { + return Services.dirsvc.get("ProfD", Ci.nsIFile).leafName; + }); + + this._cachedLivemarkInfoObjects = new Map(); +} + +PlacesController.prototype = { + /** + * The places view. + */ + _view: null, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIClipboardOwner + ]), + + // nsIClipboardOwner + LosingOwnership: function(aXferable) { + this.cutNodes = []; + }, + + terminate: function() { + this._releaseClipboardOwnership(); + }, + + supportsCommand: function(aCommand) { + // Non-Places specific commands that we also support + switch (aCommand) { + case "cmd_undo": + case "cmd_redo": + case "cmd_cut": + case "cmd_copy": + case "cmd_paste": + case "cmd_delete": + case "cmd_selectAll": + return true; + } + + // All other Places Commands are prefixed with "placesCmd_" ... this + // filters out other commands that we do _not_ support (see 329587). + const CMD_PREFIX = "placesCmd_"; + return (aCommand.substr(0, CMD_PREFIX.length) == CMD_PREFIX); + }, + + isCommandEnabled: function(aCommand) { + switch (aCommand) { + case "cmd_undo": + return PlacesUtils.transactionManager.numberOfUndoItems > 0; + case "cmd_redo": + return PlacesUtils.transactionManager.numberOfRedoItems > 0; + case "cmd_cut": + case "placesCmd_cut": + case "placesCmd_moveBookmarks": + for (let node of this._view.selectedNodes) { + // If selection includes history nodes or tags-as-bookmark, disallow + // cutting. + if (node.itemId == -1 || + (node.parent && PlacesUtils.nodeIsTagQuery(node.parent))) { + return false; + } + } + // Otherwise fall through to the cmd_delete check. + case "cmd_delete": + case "placesCmd_delete": + case "placesCmd_deleteDataHost": + return this._hasRemovableSelection(); + case "cmd_copy": + case "placesCmd_copy": + return this._view.hasSelection; + case "cmd_paste": + case "placesCmd_paste": + return this._canInsert(true) && this._isClipboardDataPasteable(); + case "cmd_selectAll": + if (this._view.selType != "single") { + let rootNode = this._view.result.root; + if (rootNode.containerOpen && rootNode.childCount > 0) + return true; + } + return false; + case "placesCmd_open": + case "placesCmd_open:window": + case "placesCmd_open:privatewindow": + case "placesCmd_open:tab": + var selectedNode = this._view.selectedNode; + return selectedNode && PlacesUtils.nodeIsURI(selectedNode); + case "placesCmd_new:folder": + case "placesCmd_new:livemark": + return this._canInsert(); + case "placesCmd_new:bookmark": + return this._canInsert(); + case "placesCmd_new:separator": + return this._canInsert() && + !PlacesUtils.asQuery(this._view.result.root).queryOptions.excludeItems && + this._view.result.sortingMode == + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + case "placesCmd_show:info": + var selectedNode = this._view.selectedNode; + return selectedNode && PlacesUtils.getConcreteItemId(selectedNode) != -1 + case "placesCmd_reload": + // Livemark containers + var selectedNode = this._view.selectedNode; + return selectedNode && this.hasCachedLivemarkInfo(selectedNode); + case "placesCmd_sortBy:name": + var selectedNode = this._view.selectedNode; + return selectedNode && + PlacesUtils.nodeIsFolder(selectedNode) && + !PlacesUIUtils.isContentsReadOnly(selectedNode) && + this._view.result.sortingMode == + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + case "placesCmd_createBookmark": + var node = this._view.selectedNode; + return node && PlacesUtils.nodeIsURI(node) && node.itemId == -1; + case "placesCmd_openParentFolder": + return true; + default: + return false; + } + }, + + doCommand: function(aCommand) { + switch (aCommand) { + case "cmd_undo": + PlacesUtils.transactionManager.undoTransaction(); + break; + case "cmd_redo": + PlacesUtils.transactionManager.redoTransaction(); + break; + case "cmd_cut": + case "placesCmd_cut": + this.cut(); + break; + case "cmd_copy": + case "placesCmd_copy": + this.copy(); + break; + case "cmd_paste": + case "placesCmd_paste": + this.paste(); + break; + case "cmd_delete": + case "placesCmd_delete": + this.remove("Remove Selection"); + break; + case "placesCmd_deleteDataHost": + var host; + if (PlacesUtils.nodeIsHost(this._view.selectedNode)) { + var queries = this._view.selectedNode.getQueries(); + host = queries[0].domain; + } + else + host = NetUtil.newURI(this._view.selectedNode.uri).host; + ForgetAboutSite.removeDataFromDomain(host) + .catch(Components.utils.reportError); + break; + case "cmd_selectAll": + this.selectAll(); + break; + case "placesCmd_open": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "current", this._view); + break; + case "placesCmd_open:window": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "window", this._view); + break; + case "placesCmd_open:privatewindow": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "window", this._view, true); + break; + case "placesCmd_open:tab": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "tab", this._view); + break; + case "placesCmd_new:folder": + this.newItem("folder"); + break; + case "placesCmd_new:bookmark": + this.newItem("bookmark"); + break; + case "placesCmd_new:livemark": + this.newItem("livemark"); + break; + case "placesCmd_new:separator": + this.newSeparator(); + break; + case "placesCmd_show:info": + this.showBookmarkPropertiesForSelection(); + break; + case "placesCmd_moveBookmarks": + this.moveSelectedBookmarks(); + break; + case "placesCmd_reload": + this.reloadSelectedLivemark(); + break; + case "placesCmd_sortBy:name": + this.sortFolderByName(); + break; + case "placesCmd_createBookmark": + let node = this._view.selectedNode; + PlacesUIUtils.showBookmarkDialog({ action: "add" + , type: "bookmark" + , hiddenRows: [ "description" + , "keyword" + , "location" + , "loadInSidebar" ] + , uri: NetUtil.newURI(node.uri) + , title: node.title + }, window.top); + break; + case "placesCmd_openParentFolder": + this.openParentFolder(); + break; + } + }, + + onEvent: function(eventName) { }, + + + /** + * Determine whether or not the selection can be removed, either by the + * delete or cut operations based on whether or not any of its contents + * are non-removable. We don't need to worry about recursion here since it + * is a policy decision that a removable item not be placed inside a non- + * removable item. + * @returns true if all nodes in the selection can be removed, + * false otherwise. + */ + _hasRemovableSelection() { + var ranges = this._view.removableSelectionRanges; + if (!ranges.length) + return false; + + var root = this._view.result.root; + + for (var j = 0; j < ranges.length; j++) { + var nodes = ranges[j]; + for (var i = 0; i < nodes.length; ++i) { + // Disallow removing the view's root node + if (nodes[i] == root) + return false; + + if (!PlacesUIUtils.canUserRemove(nodes[i])) + return false; + } + } + + return true; + }, + + /** + * Determines whether or not nodes can be inserted relative to the selection. + */ + _canInsert: function(isPaste) { + var ip = this._view.insertionPoint; + return ip != null && (isPaste || ip.isTag != true); + }, + + /** + * Looks at the data on the clipboard to see if it is paste-able. + * Paste-able data is: + * - in a format that the view can receive + * @returns true if: - clipboard data is of a TYPE_X_MOZ_PLACE_* flavor, + - clipboard data is of type TEXT_UNICODE and + is a valid URI. + */ + _isClipboardDataPasteable: function() { + // if the clipboard contains TYPE_X_MOZ_PLACE_* data, it is definitely + // pasteable, with no need to unwrap all the nodes. + + var flavors = PlacesControllerDragHelper.placesFlavors; + var clipboard = this.clipboard; + var hasPlacesData = + clipboard.hasDataMatchingFlavors(flavors, flavors.length, + Ci.nsIClipboard.kGlobalClipboard); + if (hasPlacesData) + return this._view.insertionPoint != null; + + // if the clipboard doesn't have TYPE_X_MOZ_PLACE_* data, we also allow + // pasting of valid "text/unicode" and "text/x-moz-url" data + var xferable = Cc["@mozilla.org/widget/transferable;1"]. + createInstance(Ci.nsITransferable); + xferable.init(null); + + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_URL); + xferable.addDataFlavor(PlacesUtils.TYPE_UNICODE); + clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + + try { + // getAnyTransferData will throw if no data is available. + var data = { }, type = { }; + xferable.getAnyTransferData(type, data, { }); + data = data.value.QueryInterface(Ci.nsISupportsString).data; + if (type.value != PlacesUtils.TYPE_X_MOZ_URL && + type.value != PlacesUtils.TYPE_UNICODE) + return false; + + // unwrapNodes() will throw if the data blob is malformed. + var unwrappedNodes = PlacesUtils.unwrapNodes(data, type.value); + return this._view.insertionPoint != null; + } + catch (e) { + // getAnyTransferData or unwrapNodes failed + return false; + } + }, + + /** + * Gathers information about the selected nodes according to the following + * rules: + * "link" node is a URI + * "bookmark" node is a bookmark + * "livemarkChild" node is a child of a livemark + * "tagChild" node is a child of a tag + * "folder" node is a folder + * "query" node is a query + * "separator" node is a separator line + * "host" node is a host + * + * @returns an array of objects corresponding the selected nodes. Each + * object has each of the properties above set if its corresponding + * node matches the rule. In addition, the annotations names for each + * node are set on its corresponding object as properties. + * Notes: + * 1) This can be slow, so don't call it anywhere performance critical! + */ + _buildSelectionMetadata: function() { + var metadata = []; + var nodes = this._view.selectedNodes; + + for (var i = 0; i < nodes.length; i++) { + var nodeData = {}; + var node = nodes[i]; + var nodeType = node.type; + var uri = null; + + // We don't use the nodeIs* methods here to avoid going through the type + // property way too often + switch (nodeType) { + case Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY: + nodeData["query"] = true; + if (node.parent) { + switch (PlacesUtils.asQuery(node.parent).queryOptions.resultType) { + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY: + nodeData["host"] = true; + break; + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY: + nodeData["day"] = true; + break; + } + } + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER: + case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT: + nodeData["folder"] = true; + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR: + nodeData["separator"] = true; + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_URI: + nodeData["link"] = true; + uri = NetUtil.newURI(node.uri); + if (PlacesUtils.nodeIsBookmark(node)) { + nodeData["bookmark"] = true; + var parentNode = node.parent; + if (parentNode) { + if (PlacesUtils.nodeIsTagQuery(parentNode)) + nodeData["tagChild"] = true; + } + } else { + var parentNode = node.parent; + if (parentNode) { + if (this.hasCachedLivemarkInfo(parentNode)) + nodeData["livemarkChild"] = true; + } + } + break; + } + + // annotations + if (uri) { + let names = PlacesUtils.annotations.getPageAnnotationNames(uri); + for (let j = 0; j < names.length; ++j) + nodeData[names[j]] = true; + } + + // For items also include the item-specific annotations + if (node.itemId != -1) { + let names = PlacesUtils.annotations + .getItemAnnotationNames(node.itemId); + for (let j = 0; j < names.length; ++j) + nodeData[names[j]] = true; + } + metadata.push(nodeData); + } + + return metadata; + }, + + /** + * Determines if a context-menu item should be shown + * @param aMenuItem + * the context menu item + * @param aMetaData + * meta data about the selection + * @returns true if the conditions (see buildContextMenu) are satisfied + * and the item can be displayed, false otherwise. + */ + _shouldShowMenuItem: function(aMenuItem, aMetaData) { + var selectiontype = aMenuItem.getAttribute("selectiontype"); + if (!selectiontype) { + selectiontype = "single|multiple"; + } + var selectionTypes = selectiontype.split("|"); + if (selectionTypes.indexOf("any") != -1) { + return true; + } + var count = aMetaData.length; + if (count > 1 && selectionTypes.indexOf("multiple") == -1) + return false; + if (count == 1 && selectionTypes.indexOf("single") == -1) + return false; + // NB: if there is no selection, we show the item if (and only if) + // the selectiontype includes 'none' - the metadata list will be + // empty so none of the other criteria will apply anyway. + if (count == 0) + return selectionTypes.indexOf("none") != -1; + + var forceHideAttr = aMenuItem.getAttribute("forcehideselection"); + if (forceHideAttr) { + var forceHideRules = forceHideAttr.split("|"); + for (let i = 0; i < aMetaData.length; ++i) { + for (let j = 0; j < forceHideRules.length; ++j) { + if (forceHideRules[j] in aMetaData[i]) + return false; + } + } + } + + var selectionAttr = aMenuItem.getAttribute("selection"); + if (!selectionAttr) { + return !aMenuItem.hidden; + } + + if (selectionAttr == "any") + return true; + + var showRules = selectionAttr.split("|"); + var anyMatched = false; + function metaDataNodeMatches(metaDataNode, rules) { + for (var i = 0; i < rules.length; i++) { + if (rules[i] in metaDataNode) + return true; + } + return false; + } + + for (var i = 0; i < aMetaData.length; ++i) { + if (metaDataNodeMatches(aMetaData[i], showRules)) + anyMatched = true; + else + return false; + } + return anyMatched; + }, + + /** + * Detects information (meta-data rules) about the current selection in the + * view (see _buildSelectionMetadata) and sets the visibility state for each + * of the menu-items in the given popup with the following rules applied: + * 1) The "selectiontype" attribute may be set on a menu-item to "single" + * if the menu-item should be visible only if there is a single node + * selected, or to "multiple" if the menu-item should be visible only if + * multiple nodes are selected, or to "none" if the menuitems should be + * visible for if there are no selected nodes, or to a |-separated + * combination of these. + * If the attribute is not set or set to an invalid value, the menu-item + * may be visible irrespective of the selection. + * 2) The "selection" attribute may be set on a menu-item to the various + * meta-data rules for which it may be visible. The rules should be + * separated with the | character. + * 3) A menu-item may be visible only if at least one of the rules set in + * its selection attribute apply to each of the selected nodes in the + * view. + * 4) The "forcehideselection" attribute may be set on a menu-item to rules + * for which it should be hidden. This attribute takes priority over the + * selection attribute. A menu-item would be hidden if at least one of the + * given rules apply to one of the selected nodes. The rules should be + * separated with the | character. + * 5) The "hideifnoinsertionpoint" attribute may be set on a menu-item to + * true if it should be hidden when there's no insertion point + * 6) The visibility state of a menu-item is unchanged if none of these + * attribute are set. + * 7) These attributes should not be set on separators for which the + * visibility state is "auto-detected." + * 8) The "hideifprivatebrowsing" attribute may be set on a menu-item to + * true if it should be hidden inside the private browsing mode + * @param aPopup + * The menupopup to build children into. + * @return true if at least one item is visible, false otherwise. + */ + buildContextMenu: function(aPopup) { + var metadata = this._buildSelectionMetadata(); + var ip = this._view.insertionPoint; + var noIp = !ip || ip.isTag; + + var separator = null; + var visibleItemsBeforeSep = false; + var usableItemCount = 0; + for (var i = 0; i < aPopup.childNodes.length; ++i) { + var item = aPopup.childNodes[i]; + if (item.localName != "menuseparator") { + // We allow pasting into tag containers, so special case that. + var hideIfNoIP = item.getAttribute("hideifnoinsertionpoint") == "true" && + noIp && !(ip && ip.isTag && item.id == "placesContext_paste"); + // Show the "Open Containing Folder" menu-item only when the context is + // in the Library or in the Sidebar, and only when there's no insertion + // point. + var hideParentFolderItem = item.id == "placesContext_openParentFolder" && + (!/tree/i.test(this._view.localName) || ip); + var hideIfPrivate = item.getAttribute("hideifprivatebrowsing") == "true" && + PrivateBrowsingUtils.isWindowPrivate(window); + var shouldHideItem = hideIfNoIP || hideIfPrivate || hideParentFolderItem || + !this._shouldShowMenuItem(item, metadata); + item.hidden = item.disabled = shouldHideItem; + + if (!item.hidden) { + visibleItemsBeforeSep = true; + usableItemCount++; + + // Show the separator above the menu-item if any + if (separator) { + separator.hidden = false; + separator = null; + } + } + } + else { // menuseparator + // Initially hide it. It will be unhidden if there will be at least one + // visible menu-item above and below it. + item.hidden = true; + + // We won't show the separator at all if no items are visible above it + if (visibleItemsBeforeSep) + separator = item; + + // New separator, count again: + visibleItemsBeforeSep = false; + } + } + + // Set Open Folder/Links In Tabs items enabled state if they're visible + if (usableItemCount > 0) { + var openContainerInTabsItem = document.getElementById("placesContext_openContainer:tabs"); + if (!openContainerInTabsItem.hidden) { + var containerToUse = this._view.selectedNode || this._view.result.root; + if (PlacesUtils.nodeIsContainer(containerToUse)) { + if (!PlacesUtils.hasChildURIs(containerToUse)) { + openContainerInTabsItem.disabled = true; + // Ensure that we don't display the menu if nothing is enabled: + usableItemCount--; + } + } + } + } + + return usableItemCount > 0; + }, + + /** + * Select all links in the current view. + */ + selectAll: function() { + this._view.selectAll(); + }, + + /** + * Opens the bookmark properties for the selected URI Node. + */ + showBookmarkPropertiesForSelection: + function() { + var node = this._view.selectedNode; + if (!node) + return; + + var itemType = PlacesUtils.nodeIsFolder(node) || + PlacesUtils.nodeIsTagQuery(node) ? "folder" : "bookmark"; + var concreteId = PlacesUtils.getConcreteItemId(node); + var isRootItem = PlacesUtils.isRootItem(concreteId); + var itemId = node.itemId; + if (isRootItem || PlacesUtils.nodeIsTagQuery(node)) { + // If this is a root or the Tags query we use the concrete itemId to catch + // the correct title for the node. + itemId = concreteId; + } + + PlacesUIUtils.showBookmarkDialog({ action: "edit" + , type: itemType + , itemId: itemId + , readOnly: isRootItem + , hiddenRows: [ "folderPicker" ] + }, window.top); + }, + + /** + * This method can be run on a URI parameter to ensure that it didn't + * receive a string instead of an nsIURI object. + */ + _assertURINotString: function(value) { + NS_ASSERT((typeof(value) == "object") && !(value instanceof String), + "This method should be passed a URI as a nsIURI object, not as a string."); + }, + + /** + * Reloads the selected livemark if any. + */ + reloadSelectedLivemark: function() { + var selectedNode = this._view.selectedNode; + if (selectedNode) { + let itemId = selectedNode.itemId; + PlacesUtils.livemarks.getLivemark({ id: itemId }) + .then(aLivemark => { + aLivemark.reload(true); + }, Components.utils.reportError); + } + }, + + /** + * Opens the links in the selected folder, or the selected links in new tabs. + */ + openSelectionInTabs: function(aEvent) { + var node = this._view.selectedNode; + var nodes = this._view.selectedNodes; + // In the case of no selection, open the root node: + if (!node && !nodes.length) { + node = this._view.result.root; + } + if (node && PlacesUtils.nodeIsContainer(node)) + PlacesUIUtils.openContainerNodeInTabs(node, aEvent, this._view); + else + PlacesUIUtils.openURINodesInTabs(nodes, aEvent, this._view); + }, + + /** + * Shows the Add Bookmark UI for the current insertion point. + * + * @param aType + * the type of the new item (bookmark/livemark/folder) + */ + newItem: function(aType) { + let ip = this._view.insertionPoint; + if (!ip) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + let performed = + PlacesUIUtils.showBookmarkDialog({ action: "add" + , type: aType + , defaultInsertionPoint: ip + , hiddenRows: [ "folderPicker" ] + }, window.top); + if (performed) { + // Select the new item. + let insertedNodeId = PlacesUtils.bookmarks + .getIdForItemAt(ip.itemId, ip.index); + this._view.selectItems([insertedNodeId], false); + } + }, + + /** + * Create a new Bookmark separator somewhere. + */ + newSeparator: function() { + var ip = this._view.insertionPoint; + if (!ip) + throw Cr.NS_ERROR_NOT_AVAILABLE; + var txn = new PlacesCreateSeparatorTransaction(ip.itemId, ip.index); + PlacesUtils.transactionManager.doTransaction(txn); + // select the new item + var insertedNodeId = PlacesUtils.bookmarks + .getIdForItemAt(ip.itemId, ip.index); + this._view.selectItems([insertedNodeId], false); + }, + + /** + * Opens a dialog for moving the selected nodes. + */ + moveSelectedBookmarks: function() { + window.openDialog("chrome://browser/content/places/moveBookmarks.xul", + "", "chrome, modal", + this._view.selectedNodes); + }, + + /** + * Sort the selected folder by name. + */ + sortFolderByName: function() { + var itemId = PlacesUtils.getConcreteItemId(this._view.selectedNode); + var txn = new PlacesSortFolderByNameTransaction(itemId); + PlacesUtils.transactionManager.doTransaction(txn); + }, + + /** + * Open the parent folder for the selected bookmarks search result. + */ + openParentFolder: function() { + var view; + if (!document.popupNode) { + view = document.commandDispatcher.focusedElement; + } else { + view = PlacesUIUtils.getViewForNode(document.popupNode); // XULElement + } + if (!view || view.getAttribute("type") != "places") + return; + var node = view.selectedNode; // nsINavHistoryResultNode + var aItemId = node.itemId; + var aFolderItemId = this.getParentFolderByItemId(aItemId); + if (aFolderItemId) + this.selectFolderByItemId(view, aFolderItemId, aItemId); + }, + + getParentFolderByItemId: function(aItemId) { + var bmsvc = Components.classes["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Components.interfaces.nsINavBookmarksService); + var parentFolderId = bmsvc.getFolderIdForItem(aItemId); + + return parentFolderId; + }, + + selectItems2: function(view, aIDs) { + var ids = aIDs; // Don't manipulate the caller's array. + + // Array of nodes found by findNodes which are to be selected + var nodes = []; + + // Array of nodes found by findNodes which should be opened + var nodesToOpen = []; + + // A set of URIs of container-nodes that were previously searched, + // and thus shouldn't be searched again. This is empty at the initial + // start of the recursion and gets filled in as the recursion + // progresses. + var nodesURIChecked = []; + + /** + * Recursively search through a node's children for items + * with the given IDs. When a matching item is found, remove its ID + * from the IDs array, and add the found node to the nodes dictionary. + * + * NOTE: This method will leave open any node that had matching items + * in its subtree. + */ + function findNodes(node) { + var foundOne = false; + // See if node matches an ID we wanted; add to results. + // For simple folder queries, check both itemId and the concrete + // item id. + var index = ids.indexOf(node.itemId); + if (index == -1 && + node.type == Components.interfaces.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) { + index = ids.indexOf(PlacesUtils.asQuery(node).folderItemId); //xxx Bug 556739 3.7a5pre + } + + if (index != -1) { + nodes.push(node); + foundOne = true; + ids.splice(index, 1); + } + + if (ids.length == 0 || !PlacesUtils.nodeIsContainer(node) || + nodesURIChecked.indexOf(node.uri) != -1) + return foundOne; + + nodesURIChecked.push(node.uri); + PlacesUtils.asContainer(node); // xxx Bug 556739 3.7a6pre + + // Remember the beginning state so that we can re-close + // this node if we don't find any additional results here. + var previousOpenness = node.containerOpen; + node.containerOpen = true; + for (var child = 0; child < node.childCount && ids.length > 0; + child++) { + var childNode = node.getChild(child); + var found = findNodes(childNode); + if (!foundOne) + foundOne = found; + } + + // If we didn't find any additional matches in this node's + // subtree, revert the node to its previous openness. + if (foundOne) + nodesToOpen.unshift(node); + node.containerOpen = previousOpenness; + return foundOne; + } // findNodes + + // Disable notifications while looking for nodes. + let result = view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) + result.suppressNotifications = true + try { + findNodes(view.result.root); + } + finally { + if (!didSuppressNotifications) + result.suppressNotifications = false; + } + + // For all the nodes we've found, highlight the corresponding + // index in the tree. + var resultview = view.view; + var selection = resultview.selection; + selection.selectEventsSuppressed = true; + selection.clearSelection(); + // Open nodes containing found items. + for (var i = 0; i < nodesToOpen.length; i++) { + nodesToOpen[i].containerOpen = true; + } + for (var i = 0; i < nodes.length; i++) { + if (PlacesUtils.nodeIsContainer(nodes[i])) + continue; + + var index = resultview.treeIndexForNode(nodes[i]); + selection.rangedSelect(index, index, true); + } + selection.selectEventsSuppressed = false; + }, + + selectFolderByItemId: function(view, aFolderItemId, aItemId) { + // Library + if (view.getAttribute("id") == "placeContent") { + view = document.getElementById("placesList"); + // Select a folder node in folder pane. + this.selectItems2(view, [aFolderItemId]); + view.selectItems([aFolderItemId]); + if (view.currentIndex) + view.treeBoxObject.ensureRowIsVisible(view.currentIndex); + // Reselect child node. + setTimeout(function(aItemId, view) { + var aView = view.ownerDocument.getElementById("placeContent"); + aView.selectItems([aItemId]); + if (aView.currentIndex) + aView.treeBoxObject.ensureRowIsVisible(aView.currentIndex); + }, 0, aItemId, view); + return; + } + + // Bookmarks Sidebar + if (!view) + return; + view.place = view.place; + + if ('FlatBookmarksOverlay' in window) { + var sidebarwin = view.ownerDocument.defaultView; + var searchBox = sidebarwin.document.getElementById("search-box"); + searchBox.value = ""; + searchBox.doCommand(); + sidebarwin.FlatBookmarks._setTreePlace(sidebarwin.FlatBookmarks._makePlaceForFolder(aFolderItemId)); + view.selectItems([aItemId]); + var tbo = view.treeBoxObject; + tbo.ensureRowIsVisible(view.currentIndex); + view.focus(); + return; + } + + view.findNode = function flatChildNodes(node, aIDs) { + var ids = aIDs; // Don't manipulate the caller's array. + + // Array of nodes found by findNodes which are to be selected + var nodes = []; + + // Array of nodes found by findNodes which should be opened + var nodesToOpen = []; + + // A set of URIs of container-nodes that were previously searched, + // and thus shouldn't be searched again. This is empty at the initial + // start of the recursion and gets filled in as the recursion + // progresses. + var nodesURIChecked = []; + + /** + * Recursively search through a node's children for items + * with the given IDs. When a matching item is found, remove its ID + * from the IDs array, and add the found node to the nodes dictionary. + * + * NOTE: This method will leave open any node that had matching items + * in its subtree. + */ + function findNodes(node) { + var foundOne = false; + // See if node matches an ID we wanted; add to results. + // For simple folder queries, check both itemId and the concrete + // item id. + var index = ids.indexOf(node.itemId); + if (index == -1 && + node.type == Components.interfaces.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) { + index = ids.indexOf(PlacesUtils.asQuery(node).folderItemId); // xxx Bug 556739 3.7a5pre + } + + if (index != -1) { + nodes.push(node); + foundOne = true; + ids.splice(index, 1); + } + + if (ids.length == 0 || !PlacesUtils.nodeIsContainer(node) || + nodesURIChecked.indexOf(node.uri) != -1) + return foundOne; + + nodesURIChecked.push(node.uri); + PlacesUtils.asContainer(node); // xxx Bug 556739 3.7a6pre + // Remember the beginning state so that we can re-close + // this node if we don't find any additional results here. + var previousOpenness = node.containerOpen; + node.containerOpen = true; + for (var child = 0; child < node.childCount && ids.length > 0; + child++) { + var childNode = node.getChild(child); + if (PlacesUtils.nodeIsQuery(childNode)) + continue; + var found = findNodes(childNode); + if (!foundOne) + foundOne = found; + } + + // If we didn't find any additional matches in this node's + // subtree, revert the node to its previous openness. + if (foundOne) + nodesToOpen.unshift(node); + node.containerOpen = previousOpenness; + return foundOne; + } // findNodes + + // Disable notifications while looking for nodes. + let result = this.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) + result.suppressNotifications = true + try { + findNodes(this.result.root); + } + finally { + if (!didSuppressNotifications) + result.suppressNotifications = false; + } + + // Open nodes containing found items. + for (var i = 0; i < nodesToOpen.length; i++) { + nodesToOpen[i].containerOpen = true; + } + return nodes; + }; // findNode + + // For all the nodes we've found, highlight the corresponding + // index in the tree. + var resultview = view.view; + var selection = view.view.selection; + selection.selectEventsSuppressed = true; + selection.clearSelection(); + var nodes = view.findNode(view.result.root, [aFolderItemId]); + if (nodes.length > 0) { + var index = resultview.treeIndexForNode(nodes[0]); + nodes = view.findNode(nodes[0], [aItemId]); + if (nodes.length > 0) { + index = resultview.treeIndexForNode(nodes[0]); + selection.rangedSelect(index, index, true); + } + } + selection.selectEventsSuppressed = false; + + var tbo = view.treeBoxObject; + tbo.ensureRowIsVisible(view.currentIndex); + view.focus(); + return; + }, + + /** + * Walk the list of folders we're removing in this delete operation, and + * see if the selected node specified is already implicitly being removed + * because it is a child of that folder. + * @param node + * Node to check for containment. + * @param pastFolders + * List of folders the calling function has already traversed + * @returns true if the node should be skipped, false otherwise. + */ + _shouldSkipNode: function(node, pastFolders) { + /** + * Determines if a node is contained by another node within a resultset. + * @param node + * The node to check for containment for + * @param parent + * The parent container to check for containment in + * @returns true if node is a member of parent's children, false otherwise. + */ + function isContainedBy(node, parent) { + var cursor = node.parent; + while (cursor) { + if (cursor == parent) + return true; + cursor = cursor.parent; + } + return false; + } + + for (var j = 0; j < pastFolders.length; ++j) { + if (isContainedBy(node, pastFolders[j])) + return true; + } + return false; + }, + + /** + * Creates a set of transactions for the removal of a range of items. + * A range is an array of adjacent nodes in a view. + * @param [in] range + * An array of nodes to remove. Should all be adjacent. + * @param [out] transactions + * An array of transactions. + * @param [optional] removedFolders + * An array of folder nodes that have already been removed. + */ + _removeRange: function(range, transactions, removedFolders) { + NS_ASSERT(transactions instanceof Array, "Must pass a transactions array"); + if (!removedFolders) + removedFolders = []; + + for (var i = 0; i < range.length; ++i) { + var node = range[i]; + if (this._shouldSkipNode(node, removedFolders)) + continue; + + if (PlacesUtils.nodeIsTagQuery(node.parent)) { + // This is a uri node inside a tag container. It needs a special + // untag transaction. + var tagItemId = PlacesUtils.getConcreteItemId(node.parent); + var uri = NetUtil.newURI(node.uri); + let txn = new PlacesUntagURITransaction(uri, [tagItemId]); + transactions.push(txn); + } + else if (PlacesUtils.nodeIsTagQuery(node) && node.parent && + PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.resultType == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY) { + // This is a tag container. + // Untag all URIs tagged with this tag only if the tag container is + // child of the "Tags" query in the library, in all other places we + // must only remove the query node. + var tag = node.title; + var URIs = PlacesUtils.tagging.getURIsForTag(tag); + for (var j = 0; j < URIs.length; j++) { + let txn = new PlacesUntagURITransaction(URIs[j], [tag]); + transactions.push(txn); + } + } + else if (PlacesUtils.nodeIsURI(node) && + PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + // This is a uri node inside an history query. + PlacesUtils.bhistory.removePage(NetUtil.newURI(node.uri)); + // History deletes are not undoable, so we don't have a transaction. + } + else if (node.itemId == -1 && + PlacesUtils.nodeIsQuery(node) && + PlacesUtils.asQuery(node).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + // This is a dynamically generated history query, like queries + // grouped by site, time or both. Dynamically generated queries don't + // have an itemId even if they are descendants of a bookmark. + this._removeHistoryContainer(node); + // History deletes are not undoable, so we don't have a transaction. + } + else { + // This is a common bookmark item. + if (PlacesUtils.nodeIsFolder(node)) { + // If this is a folder we add it to our array of folders, used + // to skip nodes that are children of an already removed folder. + removedFolders.push(node); + } + let txn = new PlacesRemoveItemTransaction(node.itemId); + transactions.push(txn); + } + } + }, + + /** + * Removes the set of selected ranges from bookmarks. + * @param txnName + * See |remove|. + */ + _removeRowsFromBookmarks: function(txnName) { + var ranges = this._view.removableSelectionRanges; + var transactions = []; + var removedFolders = []; + + for (var i = 0; i < ranges.length; i++) + this._removeRange(ranges[i], transactions, removedFolders); + + if (transactions.length > 0) { + var txn = new PlacesAggregatedTransaction(txnName, transactions); + PlacesUtils.transactionManager.doTransaction(txn); + } + }, + + /** + * Removes the set of selected ranges from history. + * + * @note history deletes are not undoable. + */ + _removeRowsFromHistory: function() { + let nodes = this._view.selectedNodes; + let URIs = []; + for (let i = 0; i < nodes.length; ++i) { + let node = nodes[i]; + if (PlacesUtils.nodeIsURI(node)) { + let uri = NetUtil.newURI(node.uri); + // Avoid duplicates. + if (URIs.indexOf(uri) < 0) { + URIs.push(uri); + } + } + else if (PlacesUtils.nodeIsQuery(node) && + PlacesUtils.asQuery(node).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + this._removeHistoryContainer(node); + } + } + + // Do removal in chunks to give some breath to main-thread. + function pagesChunkGenerator(aURIs) { + while (aURIs.length) { + let URIslice = aURIs.splice(0, REMOVE_PAGES_CHUNKLEN); + PlacesUtils.bhistory.removePages(URIslice, URIslice.length); + Services.tm.mainThread.dispatch(function() { + try { + gen.next(); + } catch (ex if ex instanceof StopIteration) {} + }, Ci.nsIThread.DISPATCH_NORMAL); + yield; + } + } + let gen = pagesChunkGenerator(URIs); + gen.next(); + }, + + /** + * Removes history visits for an history container node. + * @param [in] aContainerNode + * The container node to remove. + * + * @note history deletes are not undoable. + */ + _removeHistoryContainer: function(aContainerNode) { + if (PlacesUtils.nodeIsHost(aContainerNode)) { + // Site container. + PlacesUtils.bhistory.removePagesFromHost(aContainerNode.title, true); + } + else if (PlacesUtils.nodeIsDay(aContainerNode)) { + // Day container. + let query = aContainerNode.getQueries()[0]; + let beginTime = query.beginTime; + let endTime = query.endTime; + NS_ASSERT(query && beginTime && endTime, + "A valid date container query should exist!"); + // We want to exclude beginTime from the removal because + // removePagesByTimeframe includes both extremes, while date containers + // exclude the lower extreme. So, if we would not exclude it, we would + // end up removing more history than requested. + PlacesUtils.bhistory.removePagesByTimeframe(beginTime + 1, endTime); + } + }, + + /** + * Removes the selection + * @param aTxnName + * A name for the transaction if this is being performed + * as part of another operation. + */ + remove: function(aTxnName) { + if (!this._hasRemovableSelection()) + return; + + NS_ASSERT(aTxnName !== undefined, "Must supply Transaction Name"); + + var root = this._view.result.root; + + if (PlacesUtils.nodeIsFolder(root)) + this._removeRowsFromBookmarks(aTxnName); + else if (PlacesUtils.nodeIsQuery(root)) { + var queryType = PlacesUtils.asQuery(root).queryOptions.queryType; + if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS) + this._removeRowsFromBookmarks(aTxnName); + else if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) + this._removeRowsFromHistory(); + else + NS_ASSERT(false, "implement support for QUERY_TYPE_UNIFIED"); + } + else + NS_ASSERT(false, "unexpected root"); + }, + + /** + * Fills a DataTransfer object with the content of the selection that can be + * dropped elsewhere. + * @param aEvent + * The dragstart event. + */ + setDataTransfer: function(aEvent) { + let dt = aEvent.dataTransfer; + let doCopy = ["copyLink", "copy", "link"].indexOf(dt.effectAllowed) != -1; + + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) + result.suppressNotifications = true; + + function addData(type, index, feedURI) { + let wrapNode = PlacesUtils.wrapNode(node, type, feedURI); + dt.mozSetDataAt(type, wrapNode, index); + } + + function addURIData(index, feedURI) { + addData(PlacesUtils.TYPE_X_MOZ_URL, index, feedURI); + addData(PlacesUtils.TYPE_UNICODE, index, feedURI); + addData(PlacesUtils.TYPE_HTML, index, feedURI); + } + + try { + let nodes = this._view.draggableSelection; + for (let i = 0; i < nodes.length; ++i) { + var node = nodes[i]; + + // This order is _important_! It controls how this and other + // applications select data to be inserted based on type. + addData(PlacesUtils.TYPE_X_MOZ_PLACE, i); + + // Drop the feed uri for livemark containers + let livemarkInfo = this.getCachedLivemarkInfo(node); + if (livemarkInfo) { + addURIData(i, livemarkInfo.feedURI.spec); + } + else if (node.uri) { + addURIData(i); + } + } + } + finally { + if (!didSuppressNotifications) + result.suppressNotifications = false; + } + }, + + get clipboardAction () { + let action = {}; + let actionOwner; + try { + let xferable = Cc["@mozilla.org/widget/transferable;1"]. + createInstance(Ci.nsITransferable); + xferable.init(null); + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION) + this.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + xferable.getTransferData(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, action, {}); + [action, actionOwner] = + action.value.QueryInterface(Ci.nsISupportsString).data.split(","); + } catch(ex) { + // Paste from external sources don't have any associated action, just + // fallback to a copy action. + return "copy"; + } + // For cuts also check who inited the action, since cuts across different + // instances should instead be handled as copies (The sources are not + // available for this instance). + if (action == "cut" && actionOwner != this.profileName) + action = "copy"; + + return action; + }, + + _releaseClipboardOwnership: function() { + if (this.cutNodes.length > 0) { + // This clears the logical clipboard, doesn't remove data. + this.clipboard.emptyClipboard(Ci.nsIClipboard.kGlobalClipboard); + } + }, + + _clearClipboard: function() { + let xferable = Cc["@mozilla.org/widget/transferable;1"]. + createInstance(Ci.nsITransferable); + xferable.init(null); + // Empty transferables may cause crashes, so just add an unknown type. + const TYPE = "text/x-moz-place-empty"; + xferable.addDataFlavor(TYPE); + xferable.setTransferData(TYPE, PlacesUtils.toISupportsString(""), 0); + this.clipboard.setData(xferable, null, Ci.nsIClipboard.kGlobalClipboard); + }, + + _populateClipboard: function(aNodes, aAction) { + // This order is _important_! It controls how this and other applications + // select data to be inserted based on type. + let contents = [ + { type: PlacesUtils.TYPE_X_MOZ_PLACE, entries: [] }, + { type: PlacesUtils.TYPE_X_MOZ_URL, entries: [] }, + { type: PlacesUtils.TYPE_HTML, entries: [] }, + { type: PlacesUtils.TYPE_UNICODE, entries: [] }, + ]; + + // Avoid handling descendants of a copied node, the transactions take care + // of them automatically. + let copiedFolders = []; + aNodes.forEach(function(node) { + if (this._shouldSkipNode(node, copiedFolders)) + return; + if (PlacesUtils.nodeIsFolder(node)) + copiedFolders.push(node); + + let livemarkInfo = this.getCachedLivemarkInfo(node); + let feedURI = livemarkInfo && livemarkInfo.feedURI.spec; + + contents.forEach(function(content) { + content.entries.push( + PlacesUtils.wrapNode(node, content.type, feedURI) + ); + }); + }, this); + + function addData(type, data) { + xferable.addDataFlavor(type); + xferable.setTransferData(type, PlacesUtils.toISupportsString(data), + data.length * 2); + } + + let xferable = Cc["@mozilla.org/widget/transferable;1"]. + createInstance(Ci.nsITransferable); + xferable.init(null); + let hasData = false; + // This order matters here! It controls how this and other applications + // select data to be inserted based on type. + contents.forEach(function(content) { + if (content.entries.length > 0) { + hasData = true; + let glue = + content.type == PlacesUtils.TYPE_X_MOZ_PLACE ? "," : PlacesUtils.endl; + addData(content.type, content.entries.join(glue)); + } + }); + + // Track the exected action in the xferable. This must be the last flavor + // since it's the least preferred one. + // Enqueue a unique instance identifier to distinguish operations across + // concurrent instances of the application. + addData(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, aAction + "," + this.profileName); + + if (hasData) { + this.clipboard.setData(xferable, + this.cutNodes.length > 0 ? this : null, + Ci.nsIClipboard.kGlobalClipboard); + } + }, + + _cutNodes: [], + get cutNodes() this._cutNodes, + set cutNodes(aNodes) { + let self = this; + function updateCutNodes(aValue) { + self._cutNodes.forEach(function(aNode) { + self._view.toggleCutNode(aNode, aValue); + }); + } + + updateCutNodes(false); + this._cutNodes = aNodes; + updateCutNodes(true); + return aNodes; + }, + + /** + * Copy Bookmarks and Folders to the clipboard + */ + copy: function() { + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) + result.suppressNotifications = true; + try { + this._populateClipboard(this._view.selectedNodes, "copy"); + } + finally { + if (!didSuppressNotifications) + result.suppressNotifications = false; + } + }, + + /** + * Cut Bookmarks and Folders to the clipboard + */ + cut: function() { + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) + result.suppressNotifications = true; + try { + this._populateClipboard(this._view.selectedNodes, "cut"); + this.cutNodes = this._view.selectedNodes; + } + finally { + if (!didSuppressNotifications) + result.suppressNotifications = false; + } + }, + + /** + * Paste Bookmarks and Folders from the clipboard + */ + paste: function() { + // No reason to proceed if there isn't a valid insertion point. + let ip = this._view.insertionPoint; + if (!ip) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + let action = this.clipboardAction; + + let xferable = Cc["@mozilla.org/widget/transferable;1"]. + createInstance(Ci.nsITransferable); + xferable.init(null); + // This order matters here! It controls the preferred flavors for this + // paste operation. + [ PlacesUtils.TYPE_X_MOZ_PLACE, + PlacesUtils.TYPE_X_MOZ_URL, + PlacesUtils.TYPE_UNICODE, + ].forEach(function(type) xferable.addDataFlavor(type)); + + this.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + + // Now get the clipboard contents, in the best available flavor. + let data = {}, type = {}, items = []; + try { + xferable.getAnyTransferData(type, data, {}); + data = data.value.QueryInterface(Ci.nsISupportsString).data; + type = type.value; + items = PlacesUtils.unwrapNodes(data, type); + } catch(ex) { + // No supported data exists or nodes unwrap failed, just bail out. + return; + } + + let transactions = []; + let insertionIndex = ip.index; + for (let i = 0; i < items.length; ++i) { + if (ip.isTag) { + // Pasting into a tag container means tagging the item, regardless of + // the requested action. + let tagTxn = new PlacesTagURITransaction(NetUtil.newURI(items[i].uri), + [ip.itemId]); + transactions.push(tagTxn); + continue; + } + + // Adjust index to make sure items are pasted in the correct position. + // If index is DEFAULT_INDEX, items are just appended. + if (ip.index != PlacesUtils.bookmarks.DEFAULT_INDEX) + insertionIndex = ip.index + i; + + transactions.push( + PlacesUIUtils.makeTransaction(items[i], type, ip.itemId, + insertionIndex, action == "copy") + ); + } + + let aggregatedTxn = new PlacesAggregatedTransaction("Paste", transactions); + PlacesUtils.transactionManager.doTransaction(aggregatedTxn); + + // Cut/past operations are not repeatable, so clear the clipboard. + if (action == "cut") { + this._clearClipboard(); + } + + // Select the pasted items, they should be consecutive. + let insertedNodeIds = []; + for (let i = 0; i < transactions.length; ++i) { + insertedNodeIds.push( + PlacesUtils.bookmarks.getIdForItemAt(ip.itemId, ip.index + i) + ); + } + if (insertedNodeIds.length > 0) + this._view.selectItems(insertedNodeIds, false); + }, + + /** + * Cache the livemark info for a node. This allows the controller and the + * views to treat the given node as a livemark. + * @param aNode + * a places result node. + * @param aLivemarkInfo + * a mozILivemarkInfo object. + */ + cacheLivemarkInfo: function(aNode, aLivemarkInfo) { + this._cachedLivemarkInfoObjects.set(aNode, aLivemarkInfo); + }, + + /** + * Returns whether or not there's cached mozILivemarkInfo object for a node. + * @param aNode + * a places result node. + * @return true if there's a cached mozILivemarkInfo object for + * aNode, false otherwise. + */ + hasCachedLivemarkInfo: function(aNode) + this._cachedLivemarkInfoObjects.has(aNode), + + /** + * Returns the cached livemark info for a node, if set by cacheLivemarkInfo, + * null otherwise. + * @param aNode + * a places result node. + * @return the mozILivemarkInfo object for aNode, if set, null otherwise. + */ + getCachedLivemarkInfo: function(aNode) + this._cachedLivemarkInfoObjects.get(aNode, null) +}; + +/** + * Handles drag and drop operations for views. Note that this is view agnostic! + * You should not use PlacesController._view within these methods, since + * the view that the item(s) have been dropped on was not necessarily active. + * Drop functions are passed the view that is being dropped on. + */ +var PlacesControllerDragHelper = { + /** + * DOM Element currently being dragged over + */ + currentDropTarget: null, + + /** + * Determines if the mouse is currently being dragged over a child node of + * this menu. This is necessary so that the menu doesn't close while the + * mouse is dragging over one of its submenus + * @param node + * The container node + * @returns true if the user is dragging over a node within the hierarchy of + * the container, false otherwise. + */ + draggingOverChildNode: function(node) { + let currentNode = this.currentDropTarget; + while (currentNode) { + if (currentNode == node) + return true; + currentNode = currentNode.parentNode; + } + return false; + }, + + /** + * @returns The current active drag session. Returns null if there is none. + */ + getSession: function() { + return this.dragService.getCurrentSession(); + }, + + /** + * Extract the first accepted flavor from a list of flavors. + * @param aFlavors + * The flavors list of type nsIDOMDOMStringList. + */ + getFirstValidFlavor: function(aFlavors) { + for (let i = 0; i < aFlavors.length; i++) { + if (this.GENERIC_VIEW_DROP_TYPES.indexOf(aFlavors[i]) != -1) + return aFlavors[i]; + } + + // If no supported flavor is found, check if data includes text/plain + // contents. If so, request them as text/unicode, a conversion will happen + // automatically. + if (aFlavors.contains("text/plain")) { + return PlacesUtils.TYPE_UNICODE; + } + + return null; + }, + + /** + * Determines whether or not the data currently being dragged can be dropped + * on a places view. + * @param ip + * The insertion point where the items should be dropped. + */ + canDrop: function(ip, dt) { + let dropCount = dt.mozItemCount; + + // Check every dragged item. + for (let i = 0; i < dropCount; i++) { + let flavor = this.getFirstValidFlavor(dt.mozTypesAt(i)); + if (!flavor) + return false; + + // Urls can be dropped on any insertionpoint. + // XXXmano: remember that this method is called for each dragover event! + // Thus we shouldn't use unwrapNodes here at all if possible. + // I think it would be OK to accept bogus data here (e.g. text which was + // somehow wrapped as TAB_DROP_TYPE, this is not in our control, and + // will just case the actual drop to be a no-op), and only rule out valid + // expected cases, which are either unsupported flavors, or items which + // cannot be dropped in the current insertionpoint. The last case will + // likely force us to use unwrapNodes for the private data types of + // places. + if (flavor == TAB_DROP_TYPE) + continue; + + let data = dt.mozGetDataAt(flavor, i); + let dragged; + try { + dragged = PlacesUtils.unwrapNodes(data, flavor)[0]; + } + catch (e) { + return false; + } + + // Only bookmarks and urls can be dropped into tag containers. + if (ip.isTag && ip.orientation == Ci.nsITreeView.DROP_ON && + dragged.type != PlacesUtils.TYPE_X_MOZ_URL && + (dragged.type != PlacesUtils.TYPE_X_MOZ_PLACE || + (dragged.uri && dragged.uri.startsWith("place:")) )) + return false; + + // The following loop disallows the dropping of a folder on itself or + // on any of its descendants. + if (dragged.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER || + (dragged.uri && dragged.uri.startsWith("place:")) ) { + let parentId = ip.itemId; + while (parentId != PlacesUtils.placesRootId) { + if (dragged.concreteId == parentId || dragged.id == parentId) + return false; + parentId = PlacesUtils.bookmarks.getFolderIdForItem(parentId); + } + } + } + return true; + }, + + + /** + * Determines if a node can be moved. + * + * @param aNode + * A nsINavHistoryResultNode node. + * @returns True if the node can be moved, false otherwise. + */ + canMoveNode: + function(aNode) { + // Only bookmark items are movable. + if (aNode.itemId == -1) + return false; + + // Once tags and bookmarked are divorced, the tag-query check should be + // removed. + let parentNode = aNode.parent; + return parentNode != null && + !(PlacesUtils.nodeIsFolder(parentNode) && + PlacesUIUtils.isContentsReadOnly(parentNode)) && + !PlacesUtils.nodeIsTagQuery(parentNode); + }, + + /** + * Handles the drop of one or more items onto a view. + * @param insertionPoint + * The insertion point where the items should be dropped + */ + onDrop: function(insertionPoint, dt) { + let doCopy = ["copy", "link"].indexOf(dt.dropEffect) != -1; + + let transactions = []; + let dropCount = dt.mozItemCount; + let movedCount = 0; + for (let i = 0; i < dropCount; ++i) { + let flavor = this.getFirstValidFlavor(dt.mozTypesAt(i)); + if (!flavor) + return; + + let data = dt.mozGetDataAt(flavor, i); + let unwrapped; + if (flavor != TAB_DROP_TYPE) { + // There's only ever one in the D&D case. + unwrapped = PlacesUtils.unwrapNodes(data, flavor)[0]; + } + else if (data instanceof XULElement && data.localName == "tab" && + data.ownerDocument.defaultView instanceof ChromeWindow) { + let uri = data.linkedBrowser.currentURI; + let spec = uri ? uri.spec : "about:blank"; + let title = data.label; + unwrapped = { uri: spec, + title: data.label, + type: PlacesUtils.TYPE_X_MOZ_URL}; + } + else + throw("bogus data was passed as a tab"); + + let index = insertionPoint.index; + + // Adjust insertion index to prevent reversal of dragged items. When you + // drag multiple elts upward: need to increment index or each successive + // elt will be inserted at the same index, each above the previous. + let dragginUp = insertionPoint.itemId == unwrapped.parent && + index < PlacesUtils.bookmarks.getItemIndex(unwrapped.id); + if (index != -1 && dragginUp) + index += movedCount++; + + // If dragging over a tag container we should tag the item. + if (insertionPoint.isTag && + insertionPoint.orientation == Ci.nsITreeView.DROP_ON) { + let uri = NetUtil.newURI(unwrapped.uri); + let tagItemId = insertionPoint.itemId; + let tagTxn = new PlacesTagURITransaction(uri, [tagItemId]); + transactions.push(tagTxn); + } + else { + transactions.push(PlacesUIUtils.makeTransaction(unwrapped, + flavor, insertionPoint.itemId, + index, doCopy)); + } + } + + let txn = new PlacesAggregatedTransaction("DropItems", transactions); + PlacesUtils.transactionManager.doTransaction(txn); + }, + + /** + * Checks if we can insert into a container. + * @param aContainer + * The container were we are want to drop + */ + disallowInsertion: function(aContainer) { + NS_ASSERT(aContainer, "empty container"); + // Allow dropping into Tag containers and editable folders. + return !PlacesUtils.nodeIsTagQuery(aContainer) && + (!PlacesUtils.nodeIsFolder(aContainer) || + PlacesUIUtils.isContentsReadOnly(aContainer)); + }, + + placesFlavors: [PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER, + PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, + PlacesUtils.TYPE_X_MOZ_PLACE], + + // The order matters. + GENERIC_VIEW_DROP_TYPES: [PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER, + PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, + PlacesUtils.TYPE_X_MOZ_PLACE, + PlacesUtils.TYPE_X_MOZ_URL, + TAB_DROP_TYPE, + PlacesUtils.TYPE_UNICODE], +}; + + +XPCOMUtils.defineLazyServiceGetter(PlacesControllerDragHelper, "dragService", + "@mozilla.org/widget/dragservice;1", + "nsIDragService"); + +function goUpdatePlacesCommands() { + // Get the controller for one of the places commands. + var placesController = doGetPlacesControllerForCommand("placesCmd_open"); + function updatePlacesCommand(aCommand) { + goSetCommandEnabled(aCommand, placesController && + placesController.isCommandEnabled(aCommand)); + } + + updatePlacesCommand("placesCmd_open"); + updatePlacesCommand("placesCmd_open:window"); + updatePlacesCommand("placesCmd_open:privatewindow"); + updatePlacesCommand("placesCmd_open:tab"); + updatePlacesCommand("placesCmd_new:folder"); + updatePlacesCommand("placesCmd_new:bookmark"); + updatePlacesCommand("placesCmd_new:livemark"); + updatePlacesCommand("placesCmd_new:separator"); + updatePlacesCommand("placesCmd_show:info"); + updatePlacesCommand("placesCmd_moveBookmarks"); + updatePlacesCommand("placesCmd_reload"); + updatePlacesCommand("placesCmd_sortBy:name"); + updatePlacesCommand("placesCmd_openParentFolder"); + updatePlacesCommand("placesCmd_cut"); + updatePlacesCommand("placesCmd_copy"); + updatePlacesCommand("placesCmd_paste"); + updatePlacesCommand("placesCmd_delete"); +} + +function doGetPlacesControllerForCommand(aCommand) +{ + // A context menu may be built for non-focusable views. Thus, we first try + // to look for a view associated with document.popupNode + let popupNode; + try { + popupNode = document.popupNode; + } catch (e) { + // The document went away (bug 797307). + return null; + } + if (popupNode) { + let view = PlacesUIUtils.getViewForNode(popupNode); + if (view && view._contextMenuShown) + return view.controllers.getControllerForCommand(aCommand); + } + + // When we're not building a context menu, only focusable views + // are possible. Thus, we can safely use the command dispatcher. + let controller = top.document.commandDispatcher + .getControllerForCommand(aCommand); + if (controller) + return controller; + + return null; +} + +function goDoPlacesCommand(aCommand) +{ + let controller = doGetPlacesControllerForCommand(aCommand); + if (controller && controller.isCommandEnabled(aCommand)) + controller.doCommand(aCommand); +} + diff --git a/browser/components/places/content/downloadsViewOverlay.xul b/browser/components/places/content/downloadsViewOverlay.xul new file mode 100644 index 000000000..1a44dfdc0 --- /dev/null +++ b/browser/components/places/content/downloadsViewOverlay.xul @@ -0,0 +1,44 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xul-overlay href="chrome://browser/content/downloads/allDownloadsViewOverlay.xul"?> + +<!DOCTYPE overlay [ +<!ENTITY % downloadsDTD SYSTEM "chrome://browser/locale/downloads/downloads.dtd"> +%downloadsDTD; +]> + +<overlay id="downloadsViewOverlay" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript"><![CDATA[ + const DOWNLOADS_QUERY = "place:transition=" + + Components.interfaces.nsINavHistoryService.TRANSITION_DOWNLOAD + + "&sort=" + + Components.interfaces.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING; + + ContentArea.setContentViewForQueryString(DOWNLOADS_QUERY, + function() new DownloadsPlacesView(document.getElementById("downloadsRichListBox"), false), + { showDetailsPane: false, + toolbarSet: "back-button, forward-button, organizeButton, clearDownloadsButton, libraryToolbarSpacer, searchFilter" }); + ]]></script> + + <window id="places"> + <commandset id="downloadCommands"/> + <menupopup id="downloadsContextMenu"/> + </window> + + <deck id="placesViewsDeck"> + <richlistbox id="downloadsRichListBox"/> + </deck> + + <toolbar id="placesToolbar"> + <toolbarbutton id="clearDownloadsButton" + insertbefore="libraryToolbarSpacer" + label="&clearDownloadsButton.label;" + command="downloadsCmd_clearDownloads" + tooltiptext="&clearDownloadsButton.tooltip;"/> + </toolbar> + +</overlay> diff --git a/browser/components/places/content/editBookmarkOverlay.js b/browser/components/places/content/editBookmarkOverlay.js new file mode 100644 index 000000000..fcc5f5cae --- /dev/null +++ b/browser/components/places/content/editBookmarkOverlay.js @@ -0,0 +1,1063 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const LAST_USED_ANNO = "bookmarkPropertiesDialog/folderLastUsed"; +const MAX_FOLDER_ITEM_IN_MENU_LIST = 5; + +var gEditItemOverlay = { + _uri: null, + _itemId: -1, + _itemIds: [], + _uris: [], + _tags: [], + _allTags: [], + _keyword: null, + _multiEdit: false, + _itemType: -1, + _readOnly: false, + _hiddenRows: [], + _onPanelReady: false, + _observersAdded: false, + _staticFoldersListBuilt: false, + _initialized: false, + _titleOverride: "", + + // the first field which was edited after this panel was initialized for + // a certain item + _firstEditedField: "", + + get itemId() { + return this._itemId; + }, + + get uri() { + return this._uri; + }, + + get multiEdit() { + return this._multiEdit; + }, + + /** + * Determines the initial data for the item edited or added by this dialog + */ + _determineInfo: function(aInfo) { + // hidden rows + if (aInfo && aInfo.hiddenRows) + this._hiddenRows = aInfo.hiddenRows; + else + this._hiddenRows.splice(0, this._hiddenRows.length); + // force-read-only + this._readOnly = aInfo && aInfo.forceReadOnly; + this._titleOverride = aInfo && aInfo.titleOverride ? aInfo.titleOverride + : ""; + this._onPanelReady = aInfo && aInfo.onPanelReady; + }, + + _showHideRows: function() { + var isBookmark = this._itemId != -1 && + this._itemType == Ci.nsINavBookmarksService.TYPE_BOOKMARK; + var isQuery = false; + if (this._uri) + isQuery = this._uri.schemeIs("place"); + + this._element("nameRow").collapsed = this._hiddenRows.indexOf("name") != -1; + this._element("folderRow").collapsed = + this._hiddenRows.indexOf("folderPicker") != -1 || this._readOnly; + this._element("tagsRow").collapsed = !this._uri || + this._hiddenRows.indexOf("tags") != -1 || isQuery; + // Collapse the tag selector if the item does not accept tags. + if (!this._element("tagsSelectorRow").collapsed && + this._element("tagsRow").collapsed) + this.toggleTagsSelector(); + this._element("descriptionRow").collapsed = + this._hiddenRows.indexOf("description") != -1 || this._readOnly; + this._element("keywordRow").collapsed = !isBookmark || this._readOnly || + this._hiddenRows.indexOf("keyword") != -1 || isQuery; + this._element("locationRow").collapsed = !(this._uri && !isQuery) || + this._hiddenRows.indexOf("location") != -1; + this._element("loadInSidebarCheckbox").collapsed = !isBookmark || isQuery || + this._readOnly || this._hiddenRows.indexOf("loadInSidebar") != -1; + this._element("feedLocationRow").collapsed = !this._isLivemark || + this._hiddenRows.indexOf("feedLocation") != -1; + this._element("siteLocationRow").collapsed = !this._isLivemark || + this._hiddenRows.indexOf("siteLocation") != -1; + this._element("selectionCount").hidden = !this._multiEdit; + }, + + /** + * Initialize the panel + * @param aFor + * Either a places-itemId (of a bookmark, folder or a live bookmark), + * an array of itemIds (used for bulk tagging), or a URI object (in + * which case, the panel would be initialized in read-only mode). + * @param [optional] aInfo + * JS object which stores additional info for the panel + * initialization. The following properties may bet set: + * * hiddenRows (Strings array): list of rows to be hidden regardless + * of the item edited. Possible values: "title", "location", + * "description", "keyword", "loadInSidebar", "feedLocation", + * "siteLocation", folderPicker" + * * forceReadOnly - set this flag to initialize the panel to its + * read-only (view) mode even if the given item is editable. + */ + initPanel: function(aFor, aInfo) { + // For sanity ensure that the implementer has uninited the panel before + // trying to init it again, or we could end up leaking due to observers. + if (this._initialized) + this.uninitPanel(false); + + var aItemIdList; + if (Array.isArray(aFor)) { + aItemIdList = aFor; + aFor = aItemIdList[0]; + } + else if (this._multiEdit) { + this._multiEdit = false; + this._tags = []; + this._uris = []; + this._allTags = []; + this._itemIds = []; + this._element("selectionCount").hidden = true; + } + + this._folderMenuList = this._element("folderMenuList"); + this._folderTree = this._element("folderTree"); + + this._determineInfo(aInfo); + if (aFor instanceof Ci.nsIURI) { + this._itemId = -1; + this._uri = aFor; + this._readOnly = true; + } + else { + this._itemId = aFor; + // We can't store information on invalid itemIds. + this._readOnly = this._readOnly || this._itemId == -1; + + var containerId = PlacesUtils.bookmarks.getFolderIdForItem(this._itemId); + this._itemType = PlacesUtils.bookmarks.getItemType(this._itemId); + if (this._itemType == Ci.nsINavBookmarksService.TYPE_BOOKMARK) { + this._uri = PlacesUtils.bookmarks.getBookmarkURI(this._itemId); + this._keyword = PlacesUtils.bookmarks.getKeywordForBookmark(this._itemId); + this._initTextField("keywordField", this._keyword); + this._element("loadInSidebarCheckbox").checked = + PlacesUtils.annotations.itemHasAnnotation(this._itemId, + PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO); + } + else { + this._uri = null; + this._isLivemark = false; + PlacesUtils.livemarks.getLivemark({id: this._itemId }) + .then(aLivemark => { + this._isLivemark = true; + this._initTextField("feedLocationField", aLivemark.feedURI.spec, true); + this._initTextField("siteLocationField", aLivemark.siteURI ? aLivemark.siteURI.spec : "", true); + this._showHideRows(); + }, () => undefined); + } + + // folder picker + this._initFolderMenuList(containerId); + + // description field + this._initTextField("descriptionField", + PlacesUIUtils.getItemDescription(this._itemId)); + } + + if (this._itemId == -1 || + this._itemType == Ci.nsINavBookmarksService.TYPE_BOOKMARK) { + this._isLivemark = false; + + this._initTextField("locationField", this._uri.spec); + if (!aItemIdList) { + var tags = PlacesUtils.tagging.getTagsForURI(this._uri).join(", "); + this._initTextField("tagsField", tags, false); + } + else { + this._multiEdit = true; + this._allTags = []; + this._itemIds = aItemIdList; + for (var i = 0; i < aItemIdList.length; i++) { + if (aItemIdList[i] instanceof Ci.nsIURI) { + this._uris[i] = aItemIdList[i]; + this._itemIds[i] = -1; + } + else + this._uris[i] = PlacesUtils.bookmarks.getBookmarkURI(this._itemIds[i]); + this._tags[i] = PlacesUtils.tagging.getTagsForURI(this._uris[i]); + } + this._allTags = this._getCommonTags(); + this._initTextField("tagsField", this._allTags.join(", "), false); + this._element("itemsCountText").value = + PlacesUIUtils.getPluralString("detailsPane.itemsCountLabel", + this._itemIds.length, + [this._itemIds.length]); + } + + // tags selector + this._rebuildTagsSelectorList(); + } + + // name picker + this._initNamePicker(); + + this._showHideRows(); + + // observe changes + if (!this._observersAdded) { + // Single bookmarks observe any change. History entries and multiEdit + // observe only tags changes, through bookmarks. + if (this._itemId != -1 || this._uri || this._multiEdit) + PlacesUtils.bookmarks.addObserver(this, false); + + this._element("namePicker").addEventListener("blur", this); + this._element("locationField").addEventListener("blur", this); + this._element("tagsField").addEventListener("blur", this); + this._element("keywordField").addEventListener("blur", this); + this._element("descriptionField").addEventListener("blur", this); + window.addEventListener("unload", this, false); + this._observersAdded = true; + } + + let focusElement = () => { + this._initialized = true; + }; + + if (this._onPanelReady) { + this._onPanelReady(focusElement); + } else { + focusElement(); + } + }, + + /** + * Finds tags that are in common among this._tags entries that track tags + * for each selected uri. + * The tags arrays should be kept up-to-date for this to work properly. + * + * @return array of common tags for the selected uris. + */ + _getCommonTags: function() { + return this._tags[0].filter( + function(aTag) this._tags.every( + function(aTags) aTags.indexOf(aTag) != -1 + ), this + ); + }, + + _initTextField: function(aTextFieldId, aValue, aReadOnly) { + var field = this._element(aTextFieldId); + field.readOnly = aReadOnly !== undefined ? aReadOnly : this._readOnly; + + if (field.value != aValue) { + field.value = aValue; + this._editorTransactionManagerClear(field); + } + }, + + /** + * Appends a menu-item representing a bookmarks folder to a menu-popup. + * @param aMenupopup + * The popup to which the menu-item should be added. + * @param aFolderId + * The identifier of the bookmarks folder. + * @return the new menu item. + */ + _appendFolderItemToMenupopup: + function(aMenupopup, aFolderId) { + // First make sure the folders-separator is visible + this._element("foldersSeparator").hidden = false; + + var folderMenuItem = document.createElement("menuitem"); + var folderTitle = PlacesUtils.bookmarks.getItemTitle(aFolderId) + folderMenuItem.folderId = aFolderId; + folderMenuItem.setAttribute("label", folderTitle); + folderMenuItem.className = "menuitem-iconic folder-icon"; + aMenupopup.appendChild(folderMenuItem); + return folderMenuItem; + }, + + _initFolderMenuList: function(aSelectedFolder) { + // clean up first + var menupopup = this._folderMenuList.menupopup; + while (menupopup.childNodes.length > 6) + menupopup.removeChild(menupopup.lastChild); + + const bms = PlacesUtils.bookmarks; + const annos = PlacesUtils.annotations; + + // Build the static list + var unfiledItem = this._element("unfiledRootItem"); + if (!this._staticFoldersListBuilt) { + unfiledItem.label = bms.getItemTitle(PlacesUtils.unfiledBookmarksFolderId); + unfiledItem.folderId = PlacesUtils.unfiledBookmarksFolderId; + var bmMenuItem = this._element("bmRootItem"); + bmMenuItem.label = bms.getItemTitle(PlacesUtils.bookmarksMenuFolderId); + bmMenuItem.folderId = PlacesUtils.bookmarksMenuFolderId; + var toolbarItem = this._element("toolbarFolderItem"); + toolbarItem.label = bms.getItemTitle(PlacesUtils.toolbarFolderId); + toolbarItem.folderId = PlacesUtils.toolbarFolderId; + this._staticFoldersListBuilt = true; + } + + // List of recently used folders: + var folderIds = annos.getItemsWithAnnotation(LAST_USED_ANNO); + + /** + * The value of the LAST_USED_ANNO annotation is the time (in the form of + * Date.getTime) at which the folder has been last used. + * + * First we build the annotated folders array, each item has both the + * folder identifier and the time at which it was last-used by this dialog + * set. Then we sort it descendingly based on the time field. + */ + this._recentFolders = []; + for (var i = 0; i < folderIds.length; i++) { + var lastUsed = annos.getItemAnnotation(folderIds[i], LAST_USED_ANNO); + this._recentFolders.push({ folderId: folderIds[i], lastUsed: lastUsed }); + } + this._recentFolders.sort(function(a, b) { + if (b.lastUsed < a.lastUsed) + return -1; + if (b.lastUsed > a.lastUsed) + return 1; + return 0; + }); + + var numberOfItems = Math.min(MAX_FOLDER_ITEM_IN_MENU_LIST, + this._recentFolders.length); + for (var i = 0; i < numberOfItems; i++) { + this._appendFolderItemToMenupopup(menupopup, + this._recentFolders[i].folderId); + } + + var defaultItem = this._getFolderMenuItem(aSelectedFolder); + this._folderMenuList.selectedItem = defaultItem; + + // Set a selectedIndex attribute to show special icons + this._folderMenuList.setAttribute("selectedIndex", + this._folderMenuList.selectedIndex); + + // Hide the folders-separator if no folder is annotated as recently-used + this._element("foldersSeparator").hidden = (menupopup.childNodes.length <= 6); + this._folderMenuList.disabled = this._readOnly; + }, + + QueryInterface: function(aIID) { + if (aIID.equals(Ci.nsIDOMEventListener) || + aIID.equals(Ci.nsINavBookmarkObserver) || + aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + _element: function(aID) { + return document.getElementById("editBMPanel_" + aID); + }, + + _editorTransactionManagerClear: function(aItem) { + // Clear the editor's undo stack + let transactionManager; + try { + transactionManager = aItem.editor.transactionManager; + } catch (e) { + // When retrieving the transaction manager, editor may be null resulting + // in a TypeError. Additionally, the transaction manager may not + // exist yet, which causes access to it to throw NS_ERROR_FAILURE. + // In either event, the transaction manager doesn't exist it, so we + // don't need to worry about clearing it. + if (!(e instanceof TypeError) && e.result != Cr.NS_ERROR_FAILURE) { + throw e; + } + } + if (transactionManager) { + transactionManager.clear(); + } + }, + + _getItemStaticTitle: function() { + if (this._titleOverride) + return this._titleOverride; + + let title = ""; + if (this._itemId == -1) { + title = PlacesUtils.history.getPageTitle(this._uri); + } + else { + title = PlacesUtils.bookmarks.getItemTitle(this._itemId); + } + return title; + }, + + _initNamePicker: function() { + var namePicker = this._element("namePicker"); + namePicker.value = this._getItemStaticTitle(); + namePicker.readOnly = this._readOnly; + this._editorTransactionManagerClear(namePicker); + }, + + uninitPanel: function(aHideCollapsibleElements) { + if (aHideCollapsibleElements) { + // hide the folder tree if it was previously visible + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.collapsed) + this.toggleFolderTreeVisibility(); + + // hide the tag selector if it was previously visible + var tagsSelectorRow = this._element("tagsSelectorRow"); + if (!tagsSelectorRow.collapsed) + this.toggleTagsSelector(); + } + + if (this._observersAdded) { + if (this._itemId != -1 || this._uri || this._multiEdit) + PlacesUtils.bookmarks.removeObserver(this); + + this._element("namePicker").removeEventListener("blur", this); + this._element("locationField").removeEventListener("blur", this); + this._element("tagsField").removeEventListener("blur", this); + this._element("keywordField").removeEventListener("blur", this); + this._element("descriptionField").removeEventListener("blur", this); + + this._observersAdded = false; + } + + this._itemId = -1; + this._uri = null; + this._uris = []; + this._tags = []; + this._allTags = []; + this._itemIds = []; + this._multiEdit = false; + this._firstEditedField = ""; + this._initialized = false; + this._titleOverride = ""; + this._readOnly = false; + }, + + onTagsFieldBlur: function() { + if (this._updateTags()) // if anything has changed + this._mayUpdateFirstEditField("tagsField"); + }, + + _updateTags: function() { + if (this._multiEdit) + return this._updateMultipleTagsForItems(); + return this._updateSingleTagForItem(); + }, + + _updateSingleTagForItem: function() { + var currentTags = PlacesUtils.tagging.getTagsForURI(this._uri); + var tags = this._getTagsArrayFromTagField(); + if (tags.length > 0 || currentTags.length > 0) { + var tagsToRemove = []; + var tagsToAdd = []; + var txns = []; + for (var i = 0; i < currentTags.length; i++) { + if (tags.indexOf(currentTags[i]) == -1) + tagsToRemove.push(currentTags[i]); + } + for (var i = 0; i < tags.length; i++) { + if (currentTags.indexOf(tags[i]) == -1) + tagsToAdd.push(tags[i]); + } + + if (tagsToRemove.length > 0) { + let untagTxn = new PlacesUntagURITransaction(this._uri, tagsToRemove); + txns.push(untagTxn); + } + if (tagsToAdd.length > 0) { + let tagTxn = new PlacesTagURITransaction(this._uri, tagsToAdd); + txns.push(tagTxn); + } + + if (txns.length > 0) { + let aggregate = new PlacesAggregatedTransaction("Update tags", txns); + PlacesUtils.transactionManager.doTransaction(aggregate); + + // Ensure the tagsField is in sync, clean it up from empty tags + var tags = PlacesUtils.tagging.getTagsForURI(this._uri).join(", "); + this._initTextField("tagsField", tags, false); + return true; + } + } + return false; + }, + + /** + * Stores the first-edit field for this dialog, if the passed-in field + * is indeed the first edited field + * @param aNewField + * the id of the field that may be set (without the "editBMPanel_" + * prefix) + */ + _mayUpdateFirstEditField: function(aNewField) { + // * The first-edit-field behavior is not applied in the multi-edit case + // * if this._firstEditedField is already set, this is not the first field, + // so there's nothing to do + if (this._multiEdit || this._firstEditedField) + return; + + this._firstEditedField = aNewField; + + // set the pref + var prefs = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefBranch); + prefs.setCharPref("browser.bookmarks.editDialog.firstEditField", aNewField); + }, + + _updateMultipleTagsForItems: function() { + var tags = this._getTagsArrayFromTagField(); + if (tags.length > 0 || this._allTags.length > 0) { + var tagsToRemove = []; + var tagsToAdd = []; + var txns = []; + for (var i = 0; i < this._allTags.length; i++) { + if (tags.indexOf(this._allTags[i]) == -1) + tagsToRemove.push(this._allTags[i]); + } + for (var i = 0; i < this._tags.length; i++) { + tagsToAdd[i] = []; + for (var j = 0; j < tags.length; j++) { + if (this._tags[i].indexOf(tags[j]) == -1) + tagsToAdd[i].push(tags[j]); + } + } + + if (tagsToAdd.length > 0) { + for (let i = 0; i < this._uris.length; i++) { + if (tagsToAdd[i].length > 0) { + let tagTxn = new PlacesTagURITransaction(this._uris[i], + tagsToAdd[i]); + txns.push(tagTxn); + } + } + } + if (tagsToRemove.length > 0) { + for (let i = 0; i < this._uris.length; i++) { + let untagTxn = new PlacesUntagURITransaction(this._uris[i], + tagsToRemove); + txns.push(untagTxn); + } + } + + if (txns.length > 0) { + let aggregate = new PlacesAggregatedTransaction("Update tags", txns); + PlacesUtils.transactionManager.doTransaction(aggregate); + + this._allTags = tags; + this._tags = []; + for (let i = 0; i < this._uris.length; i++) { + this._tags[i] = PlacesUtils.tagging.getTagsForURI(this._uris[i]); + } + + // Ensure the tagsField is in sync, clean it up from empty tags + this._initTextField("tagsField", tags, false); + return true; + } + } + return false; + }, + + onNamePickerBlur: function() { + if (this._itemId == -1) + return; + + var namePicker = this._element("namePicker") + + // Here we update either the item title or its cached static title + var newTitle = namePicker.value; + if (!newTitle && + PlacesUtils.bookmarks.getFolderIdForItem(this._itemId) == PlacesUtils.tagsFolderId) { + // We don't allow setting an empty title for a tag, restore the old one. + this._initNamePicker(); + } + else if (this._getItemStaticTitle() != newTitle) { + this._mayUpdateFirstEditField("namePicker"); + let txn = new PlacesEditItemTitleTransaction(this._itemId, newTitle); + PlacesUtils.transactionManager.doTransaction(txn); + } + }, + + onDescriptionFieldBlur: function() { + var description = this._element("descriptionField").value; + if (description != PlacesUIUtils.getItemDescription(this._itemId)) { + var annoObj = { name : PlacesUIUtils.DESCRIPTION_ANNO, + type : Ci.nsIAnnotationService.TYPE_STRING, + flags : 0, + value : description, + expires: Ci.nsIAnnotationService.EXPIRE_NEVER }; + var txn = new PlacesSetItemAnnotationTransaction(this._itemId, annoObj); + PlacesUtils.transactionManager.doTransaction(txn); + } + }, + + onLocationFieldBlur: function() { + var uri; + try { + uri = PlacesUIUtils.createFixedURI(this._element("locationField").value); + } + catch(ex) { return; } + + if (!this._uri.equals(uri)) { + var txn = new PlacesEditBookmarkURITransaction(this._itemId, uri); + PlacesUtils.transactionManager.doTransaction(txn); + this._uri = uri; + } + }, + + onKeywordFieldBlur: function() { + let oldKeyword = this._keyword; + let keyword = this._keyword = this._element("keywordField").value; + if (keyword != oldKeyword) { + let txn = new PlacesEditBookmarkKeywordTransaction(this._itemId, + keyword, + null, + oldKeyword); + PlacesUtils.transactionManager.doTransaction(txn); + } + }, + + onLoadInSidebarCheckboxCommand: + function() { + let annoObj = { name : PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO }; + if (this._element("loadInSidebarCheckbox").checked) + annoObj.value = true; + let txn = new PlacesSetItemAnnotationTransaction(this._itemId, annoObj); + PlacesUtils.transactionManager.doTransaction(txn); + }, + + toggleFolderTreeVisibility: function() { + var expander = this._element("foldersExpander"); + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.collapsed) { + expander.className = "expander-down"; + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextdown")); + folderTreeRow.collapsed = true; + this._element("chooseFolderSeparator").hidden = + this._element("chooseFolderMenuItem").hidden = false; + } + else { + expander.className = "expander-up" + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextup")); + folderTreeRow.collapsed = false; + + // XXXmano: Ideally we would only do this once, but for some odd reason, + // the editable mode set on this tree, together with its collapsed state + // breaks the view. + const FOLDER_TREE_PLACE_URI = + "place:excludeItems=1&excludeQueries=1&excludeReadOnlyFolders=1&folder=" + + PlacesUIUtils.allBookmarksFolderId; + this._folderTree.place = FOLDER_TREE_PLACE_URI; + + this._element("chooseFolderSeparator").hidden = + this._element("chooseFolderMenuItem").hidden = true; + var currentFolder = this._getFolderIdFromMenuList(); + this._folderTree.selectItems([currentFolder]); + this._folderTree.focus(); + } + }, + + _getFolderIdFromMenuList: + function() { + var selectedItem = this._folderMenuList.selectedItem; + NS_ASSERT("folderId" in selectedItem, + "Invalid menuitem in the folders-menulist"); + return selectedItem.folderId; + }, + + /** + * Get the corresponding menu-item in the folder-menu-list for a bookmarks + * folder if such an item exists. Otherwise, this creates a menu-item for the + * folder. If the items-count limit (see MAX_FOLDERS_IN_MENU_LIST) is reached, + * the new item replaces the last menu-item. + * @param aFolderId + * The identifier of the bookmarks folder. + */ + _getFolderMenuItem: + function(aFolderId) { + var menupopup = this._folderMenuList.menupopup; + + for (let i = 0; i < menupopup.childNodes.length; i++) { + if ("folderId" in menupopup.childNodes[i] && + menupopup.childNodes[i].folderId == aFolderId) + return menupopup.childNodes[i]; + } + + // 3 special folders + separator + folder-items-count limit + if (menupopup.childNodes.length == 4 + MAX_FOLDER_ITEM_IN_MENU_LIST) + menupopup.removeChild(menupopup.lastChild); + + return this._appendFolderItemToMenupopup(menupopup, aFolderId); + }, + + onFolderMenuListCommand: function(aEvent) { + // Set a selectedIndex attribute to show special icons + this._folderMenuList.setAttribute("selectedIndex", + this._folderMenuList.selectedIndex); + + if (aEvent.target.id == "editBMPanel_chooseFolderMenuItem") { + // reset the selection back to where it was and expand the tree + // (this menu-item is hidden when the tree is already visible + var container = PlacesUtils.bookmarks.getFolderIdForItem(this._itemId); + var item = this._getFolderMenuItem(container); + this._folderMenuList.selectedItem = item; + // XXXmano HACK: setTimeout 100, otherwise focus goes back to the + // menulist right away + setTimeout(function(self) self.toggleFolderTreeVisibility(), 100, this); + return; + } + + // Move the item + var container = this._getFolderIdFromMenuList(); + if (PlacesUtils.bookmarks.getFolderIdForItem(this._itemId) != container) { + var txn = new PlacesMoveItemTransaction(this._itemId, + container, + PlacesUtils.bookmarks.DEFAULT_INDEX); + PlacesUtils.transactionManager.doTransaction(txn); + + // Mark the containing folder as recently-used if it isn't in the + // static list + if (container != PlacesUtils.unfiledBookmarksFolderId && + container != PlacesUtils.toolbarFolderId && + container != PlacesUtils.bookmarksMenuFolderId) + this._markFolderAsRecentlyUsed(container); + } + + // Update folder-tree selection + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.collapsed) { + var selectedNode = this._folderTree.selectedNode; + if (!selectedNode || + PlacesUtils.getConcreteItemId(selectedNode) != container) + this._folderTree.selectItems([container]); + } + }, + + onFolderTreeSelect: function() { + var selectedNode = this._folderTree.selectedNode; + + // Disable the "New Folder" button if we cannot create a new folder + this._element("newFolderButton") + .disabled = !this._folderTree.insertionPoint || !selectedNode; + + if (!selectedNode) + return; + + var folderId = PlacesUtils.getConcreteItemId(selectedNode); + if (this._getFolderIdFromMenuList() == folderId) + return; + + var folderItem = this._getFolderMenuItem(folderId); + this._folderMenuList.selectedItem = folderItem; + folderItem.doCommand(); + }, + + _markFolderAsRecentlyUsed: + function(aFolderId) { + var txns = []; + + // Expire old unused recent folders + var anno = this._getLastUsedAnnotationObject(false); + while (this._recentFolders.length > MAX_FOLDER_ITEM_IN_MENU_LIST) { + var folderId = this._recentFolders.pop().folderId; + let annoTxn = new PlacesSetItemAnnotationTransaction(folderId, anno); + txns.push(annoTxn); + } + + // Mark folder as recently used + anno = this._getLastUsedAnnotationObject(true); + let annoTxn = new PlacesSetItemAnnotationTransaction(aFolderId, anno); + txns.push(annoTxn); + + let aggregate = new PlacesAggregatedTransaction("Update last used folders", txns); + PlacesUtils.transactionManager.doTransaction(aggregate); + }, + + /** + * Returns an object which could then be used to set/unset the + * LAST_USED_ANNO annotation for a folder. + * + * @param aLastUsed + * Whether to set or unset the LAST_USED_ANNO annotation. + * @returns an object representing the annotation which could then be used + * with the transaction manager. + */ + _getLastUsedAnnotationObject: + function(aLastUsed) { + var anno = { name: LAST_USED_ANNO, + type: Ci.nsIAnnotationService.TYPE_INT32, + flags: 0, + value: aLastUsed ? new Date().getTime() : null, + expires: Ci.nsIAnnotationService.EXPIRE_NEVER }; + + return anno; + }, + + _rebuildTagsSelectorList: function() { + var tagsSelector = this._element("tagsSelector"); + var tagsSelectorRow = this._element("tagsSelectorRow"); + if (tagsSelectorRow.collapsed) + return; + + // Save the current scroll position and restore it after the rebuild. + let firstIndex = tagsSelector.getIndexOfFirstVisibleRow(); + let selectedIndex = tagsSelector.selectedIndex; + let selectedTag = selectedIndex >= 0 ? tagsSelector.selectedItem.label + : null; + + while (tagsSelector.hasChildNodes()) + tagsSelector.removeChild(tagsSelector.lastChild); + + var tagsInField = this._getTagsArrayFromTagField(); + var allTags = PlacesUtils.tagging.allTags; + for (var i = 0; i < allTags.length; i++) { + var tag = allTags[i]; + var elt = document.createElement("listitem"); + elt.setAttribute("type", "checkbox"); + elt.setAttribute("label", tag); + if (tagsInField.indexOf(tag) != -1) + elt.setAttribute("checked", "true"); + tagsSelector.appendChild(elt); + if (selectedTag === tag) + selectedIndex = tagsSelector.getIndexOfItem(elt); + } + + // Restore position. + // The listbox allows to scroll only if the required offset doesn't + // overflow its capacity, thus need to adjust the index for removals. + firstIndex = + Math.min(firstIndex, + tagsSelector.itemCount - tagsSelector.getNumberOfVisibleRows()); + tagsSelector.scrollToIndex(firstIndex); + if (selectedIndex >= 0 && tagsSelector.itemCount > 0) { + selectedIndex = Math.min(selectedIndex, tagsSelector.itemCount - 1); + tagsSelector.selectedIndex = selectedIndex; + tagsSelector.ensureIndexIsVisible(selectedIndex); + } + }, + + toggleTagsSelector: function() { + var tagsSelector = this._element("tagsSelector"); + var tagsSelectorRow = this._element("tagsSelectorRow"); + var expander = this._element("tagsSelectorExpander"); + if (tagsSelectorRow.collapsed) { + expander.className = "expander-up"; + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextup")); + tagsSelectorRow.collapsed = false; + this._rebuildTagsSelectorList(); + + // This is a no-op if we've added the listener. + tagsSelector.addEventListener("CheckboxStateChange", this, false); + } + else { + expander.className = "expander-down"; + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextdown")); + tagsSelectorRow.collapsed = true; + } + }, + + /** + * Splits "tagsField" element value, returning an array of valid tag strings. + * + * @return Array of tag strings found in the field value. + */ + _getTagsArrayFromTagField: function() { + let tags = this._element("tagsField").value; + return tags.trim() + .split(/\s*,\s*/) // Split on commas and remove spaces. + .filter(function(tag) tag.length > 0); // Kill empty tags. + }, + + newFolder: function() { + var ip = this._folderTree.insertionPoint; + + // default to the bookmarks menu folder + if (!ip || ip.itemId == PlacesUIUtils.allBookmarksFolderId) { + ip = new InsertionPoint(PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + Ci.nsITreeView.DROP_ON); + } + + // XXXmano: add a separate "New Folder" string at some point... + var defaultLabel = this._element("newFolderButton").label; + var txn = new PlacesCreateFolderTransaction(defaultLabel, ip.itemId, ip.index); + PlacesUtils.transactionManager.doTransaction(txn); + this._folderTree.focus(); + this._folderTree.selectItems([this._lastNewItem]); + this._folderTree.startEditing(this._folderTree.view.selection.currentIndex, + this._folderTree.columns.getFirstColumn()); + }, + + // nsIDOMEventListener + handleEvent: function(aEvent) { + switch (aEvent.type) { + case "CheckboxStateChange": + // Update the tags field when items are checked/unchecked in the listbox + var tags = this._getTagsArrayFromTagField(); + + if (aEvent.target.checked) { + if (tags.indexOf(aEvent.target.label) == -1) + tags.push(aEvent.target.label); + } + else { + var indexOfItem = tags.indexOf(aEvent.target.label); + if (indexOfItem != -1) + tags.splice(indexOfItem, 1); + } + this._element("tagsField").value = tags.join(", "); + this._updateTags(); + break; + case "blur": + let replaceFn = (str, firstLetter) => firstLetter.toUpperCase(); + let nodeName = aEvent.target.id.replace(/editBMPanel_(\w)/, replaceFn); + this["on" + nodeName + "Blur"](); + break; + case "unload": + this.uninitPanel(false); + break; + } + }, + + // nsINavBookmarkObserver + onItemChanged: function(aItemId, aProperty, + aIsAnnotationProperty, aValue, + aLastModified, aItemType) { + if (aProperty == "tags") { + // Tags case is special, since they should be updated if either: + // - the notification is for the edited bookmark + // - the notification is for the edited history entry + // - the notification is for one of edited uris + let shouldUpdateTagsField = this._itemId == aItemId; + if (this._itemId == -1 || this._multiEdit) { + // Check if the changed uri is part of the modified ones. + let changedURI = PlacesUtils.bookmarks.getBookmarkURI(aItemId); + let uris = this._multiEdit ? this._uris : [this._uri]; + uris.forEach(function(aURI, aIndex) { + if (aURI.equals(changedURI)) { + shouldUpdateTagsField = true; + if (this._multiEdit) { + this._tags[aIndex] = PlacesUtils.tagging.getTagsForURI(this._uris[aIndex]); + } + } + }, this); + } + + if (shouldUpdateTagsField) { + if (this._multiEdit) { + this._allTags = this._getCommonTags(); + this._initTextField("tagsField", this._allTags.join(", "), false); + } + else { + let tags = PlacesUtils.tagging.getTagsForURI(this._uri).join(", "); + this._initTextField("tagsField", tags, false); + } + } + + // Any tags change should be reflected in the tags selector. + this._rebuildTagsSelectorList(); + return; + } + + if (this._itemId != aItemId) { + if (aProperty == "title") { + // If the title of a folder which is listed within the folders + // menulist has been changed, we need to update the label of its + // representing element. + var menupopup = this._folderMenuList.menupopup; + for (let i = 0; i < menupopup.childNodes.length; i++) { + if ("folderId" in menupopup.childNodes[i] && + menupopup.childNodes[i].folderId == aItemId) { + menupopup.childNodes[i].label = aValue; + break; + } + } + } + + return; + } + + switch (aProperty) { + case "title": + var namePicker = this._element("namePicker"); + if (namePicker.value != aValue) { + namePicker.value = aValue; + this._editorTransactionManagerClear(namePicker); + } + break; + case "uri": + var locationField = this._element("locationField"); + if (locationField.value != aValue) { + this._uri = Cc["@mozilla.org/network/io-service;1"]. + getService(Ci.nsIIOService). + newURI(aValue, null, null); + this._initTextField("locationField", this._uri.spec); + this._initNamePicker(); + this._initTextField("tagsField", + PlacesUtils.tagging + .getTagsForURI(this._uri).join(", "), + false); + this._rebuildTagsSelectorList(); + } + break; + case "keyword": + this._keyword = PlacesUtils.bookmarks.getKeywordForBookmark(this._itemId); + this._initTextField("keywordField", this._keyword); + break; + case PlacesUIUtils.DESCRIPTION_ANNO: + this._initTextField("descriptionField", + PlacesUIUtils.getItemDescription(this._itemId)); + break; + case PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO: + this._element("loadInSidebarCheckbox").checked = + PlacesUtils.annotations.itemHasAnnotation(this._itemId, + PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO); + break; + case PlacesUtils.LMANNO_FEEDURI: + let feedURISpec = + PlacesUtils.annotations.getItemAnnotation(this._itemId, + PlacesUtils.LMANNO_FEEDURI); + this._initTextField("feedLocationField", feedURISpec, true); + break; + case PlacesUtils.LMANNO_SITEURI: + let siteURISpec = ""; + try { + siteURISpec = + PlacesUtils.annotations.getItemAnnotation(this._itemId, + PlacesUtils.LMANNO_SITEURI); + } catch (ex) {} + this._initTextField("siteLocationField", siteURISpec, true); + break; + } + }, + + onItemMoved: function(aItemId, aOldParent, aOldIndex, + aNewParent, aNewIndex, aItemType) { + if (aItemId != this._itemId || + aNewParent == this._getFolderIdFromMenuList()) + return; + + var folderItem = this._getFolderMenuItem(aNewParent); + + // just setting selectItem _does not_ trigger oncommand, so we don't + // recurse + this._folderMenuList.selectedItem = folderItem; + }, + + onItemAdded: function(aItemId, aParentId, aIndex, aItemType, + aURI) { + this._lastNewItem = aItemId; + }, + + onItemRemoved: function() { }, + onBeginUpdateBatch: function() { }, + onEndUpdateBatch: function() { }, + onItemVisited: function() { }, +}; diff --git a/browser/components/places/content/editBookmarkOverlay.xul b/browser/components/places/content/editBookmarkOverlay.xul new file mode 100644 index 000000000..196369dd2 --- /dev/null +++ b/browser/components/places/content/editBookmarkOverlay.xul @@ -0,0 +1,228 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE overlay [ +<!ENTITY % editBookmarkOverlayDTD SYSTEM "chrome://browser/locale/places/editBookmarkOverlay.dtd"> +%editBookmarkOverlayDTD; +]> + +<?xml-stylesheet href="chrome://browser/skin/places/editBookmarkOverlay.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/places.css"?> + +<overlay id="editBookmarkOverlay" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <vbox id="editBookmarkPanelContent" flex="1"> + <broadcaster id="paneElementsBroadcaster"/> + + <hbox id="editBMPanel_selectionCount" hidden="true" pack="center"> + <label id="editBMPanel_itemsCountText"/> + </hbox> + + <grid id="editBookmarkPanelGrid" flex="1"> + <columns id="editBMPanel_columns"> + <column id="editBMPanel_labelColumn" /> + <column flex="1" id="editBMPanel_editColumn" /> + </columns> + <rows id="editBMPanel_rows"> + <row id="editBMPanel_nameRow" + align="center" + collapsed="true"> + <label value="&editBookmarkOverlay.name.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.name.accesskey;" + control="editBMPanel_namePicker" + observes="paneElementsBroadcaster"/> + <textbox id="editBMPanel_namePicker" + observes="paneElementsBroadcaster"/> + </row> + + <row id="editBMPanel_locationRow" + align="center" + collapsed="true"> + <label value="&editBookmarkOverlay.location.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.location.accesskey;" + control="editBMPanel_locationField" + observes="paneElementsBroadcaster"/> + <textbox id="editBMPanel_locationField" + class="uri-element" + observes="paneElementsBroadcaster"/> + </row> + + <row id="editBMPanel_feedLocationRow" + align="center" + collapsed="true"> + <label value="&editBookmarkOverlay.feedLocation.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.feedLocation.accesskey;" + control="editBMPanel_feedLocationField" + observes="paneElementsBroadcaster"/> + <textbox id="editBMPanel_feedLocationField" + class="uri-element" + observes="paneElementsBroadcaster"/> + </row> + + <row id="editBMPanel_siteLocationRow" + align="center" + collapsed="true"> + <label value="&editBookmarkOverlay.siteLocation.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.siteLocation.accesskey;" + control="editBMPanel_siteLocationField" + observes="paneElementsBroadcaster"/> + <textbox id="editBMPanel_siteLocationField" + class="uri-element" + observes="paneElementsBroadcaster"/> + </row> + + <row id="editBMPanel_folderRow" + align="center" + collapsed="true"> + <label value="&editBookmarkOverlay.folder.label;" + class="editBMPanel_rowLabel" + control="editBMPanel_folderMenuList" + observes="paneElementsBroadcaster"/> + <hbox flex="1" align="center"> + <menulist id="editBMPanel_folderMenuList" + class="folder-icon" + flex="1" + oncommand="gEditItemOverlay.onFolderMenuListCommand(event);" + observes="paneElementsBroadcaster"> + <menupopup> + <!-- Static item for special folders --> + <menuitem id="editBMPanel_toolbarFolderItem" + class="menuitem-iconic folder-icon"/> + <menuitem id="editBMPanel_bmRootItem" + class="menuitem-iconic folder-icon"/> + <menuitem id="editBMPanel_unfiledRootItem" + class="menuitem-iconic folder-icon"/> + <menuseparator id="editBMPanel_chooseFolderSeparator"/> + <menuitem id="editBMPanel_chooseFolderMenuItem" + label="&editBookmarkOverlay.choose.label;" + class="menuitem-iconic folder-icon"/> + <menuseparator id="editBMPanel_foldersSeparator" hidden="true"/> + </menupopup> + </menulist> + <button id="editBMPanel_foldersExpander" + class="expander-down" + tooltiptext="&editBookmarkOverlay.foldersExpanderDown.tooltip;" + tooltiptextdown="&editBookmarkOverlay.foldersExpanderDown.tooltip;" + tooltiptextup="&editBookmarkOverlay.expanderUp.tooltip;" + oncommand="gEditItemOverlay.toggleFolderTreeVisibility();" + observes="paneElementsBroadcaster"/> + </hbox> + </row> + + <row id="editBMPanel_folderTreeRow" + collapsed="true" + flex="1"> + <spacer/> + <vbox flex="1"> + <tree id="editBMPanel_folderTree" + flex="1" + class="placesTree" + type="places" + height="150" + minheight="150" + editable="true" + onselect="gEditItemOverlay.onFolderTreeSelect();" + hidecolumnpicker="true" + observes="paneElementsBroadcaster"> + <treecols> + <treecol anonid="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <hbox id="editBMPanel_newFolderBox"> + <button label="&editBookmarkOverlay.newFolderButton.label;" + id="editBMPanel_newFolderButton" + accesskey="&editBookmarkOverlay.newFolderButton.accesskey;" + oncommand="gEditItemOverlay.newFolder();"/> + </hbox> + </vbox> + </row> + + <row id="editBMPanel_tagsRow" + align="center" + collapsed="true"> + <label value="&editBookmarkOverlay.tags.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.tags.accesskey;" + control="editBMPanel_tagsField" + observes="paneElementsBroadcaster"/> + <hbox flex="1" align="center"> + <textbox id="editBMPanel_tagsField" + type="autocomplete" + class="padded" + flex="1" + autocompletesearch="places-tag-autocomplete" + completedefaultindex="true" + tabscrolling="true" + showcommentcolumn="true" + observes="paneElementsBroadcaster" + placeholder="&editBookmarkOverlay.tagsEmptyDesc.label;"/> + <button id="editBMPanel_tagsSelectorExpander" + class="expander-down" + tooltiptext="&editBookmarkOverlay.tagsExpanderDown.tooltip;" + tooltiptextdown="&editBookmarkOverlay.tagsExpanderDown.tooltip;" + tooltiptextup="&editBookmarkOverlay.expanderUp.tooltip;" + oncommand="gEditItemOverlay.toggleTagsSelector();" + observes="paneElementsBroadcaster"/> + </hbox> + </row> + + <row id="editBMPanel_tagsSelectorRow" + align="center" + collapsed="true"> + <spacer/> + <listbox id="editBMPanel_tagsSelector" + height="150" + observes="paneElementsBroadcaster"/> + </row> + + <row id="editBMPanel_keywordRow" + align="center" + collapsed="true"> + <observes element="additionalInfoBroadcaster" attribute="hidden"/> + <label value="&editBookmarkOverlay.keyword.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.keyword.accesskey;" + control="editBMPanel_keywordField" + observes="paneElementsBroadcaster"/> + <textbox id="editBMPanel_keywordField" + observes="paneElementsBroadcaster"/> + </row> + + <row id="editBMPanel_descriptionRow" + collapsed="true"> + <observes element="additionalInfoBroadcaster" attribute="hidden"/> + <label value="&editBookmarkOverlay.description.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.description.accesskey;" + control="editBMPanel_descriptionField" + observes="paneElementsBroadcaster"/> + <textbox id="editBMPanel_descriptionField" + multiline="true" + observes="paneElementsBroadcaster"/> + </row> + </rows> + </grid> + + <checkbox id="editBMPanel_loadInSidebarCheckbox" + collapsed="true" + label="&editBookmarkOverlay.loadInSidebar.label;" + accesskey="&editBookmarkOverlay.loadInSidebar.accesskey;" + oncommand="gEditItemOverlay.onLoadInSidebarCheckboxCommand();" + observes="paneElementsBroadcaster"> + <observes element="additionalInfoBroadcaster" attribute="hidden"/> + </checkbox> + + <!-- If the ids are changing or additional fields are being added, be sure + to sync the values in places.js --> + <broadcaster id="additionalInfoBroadcaster"/> + + </vbox> +</overlay> diff --git a/browser/components/places/content/history-panel.js b/browser/components/places/content/history-panel.js new file mode 100644 index 000000000..cda39dd26 --- /dev/null +++ b/browser/components/places/content/history-panel.js @@ -0,0 +1,91 @@ +/* -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var gHistoryTree; +var gSearchBox; +var gHistoryGrouping = ""; +var gSearching = false; + +function HistorySidebarInit() +{ + gHistoryTree = document.getElementById("historyTree"); + gSearchBox = document.getElementById("search-box"); + + gHistoryGrouping = document.getElementById("viewButton"). + getAttribute("selectedsort"); + + if (gHistoryGrouping == "site") + document.getElementById("bysite").setAttribute("checked", "true"); + else if (gHistoryGrouping == "visited") + document.getElementById("byvisited").setAttribute("checked", "true"); + else if (gHistoryGrouping == "lastvisited") + document.getElementById("bylastvisited").setAttribute("checked", "true"); + else if (gHistoryGrouping == "dayandsite") + document.getElementById("bydayandsite").setAttribute("checked", "true"); + else + document.getElementById("byday").setAttribute("checked", "true"); + + searchHistory(""); +} + +function GroupBy(groupingType) +{ + gHistoryGrouping = groupingType; + searchHistory(gSearchBox.value); +} + +function searchHistory(aInput) +{ + var query = PlacesUtils.history.getNewQuery(); + var options = PlacesUtils.history.getNewQueryOptions(); + + const NHQO = Ci.nsINavHistoryQueryOptions; + var sortingMode; + var resultType; + + switch (gHistoryGrouping) { + case "visited": + resultType = NHQO.RESULTS_AS_URI; + sortingMode = NHQO.SORT_BY_VISITCOUNT_DESCENDING; + break; + case "lastvisited": + resultType = NHQO.RESULTS_AS_URI; + sortingMode = NHQO.SORT_BY_DATE_DESCENDING; + break; + case "dayandsite": + resultType = NHQO.RESULTS_AS_DATE_SITE_QUERY; + break; + case "site": + resultType = NHQO.RESULTS_AS_SITE_QUERY; + sortingMode = NHQO.SORT_BY_TITLE_ASCENDING; + break; + case "day": + default: + resultType = NHQO.RESULTS_AS_DATE_QUERY; + break; + } + + if (aInput) { + query.searchTerms = aInput; + if (gHistoryGrouping != "visited" && gHistoryGrouping != "lastvisited") { + sortingMode = NHQO.SORT_BY_FRECENCY_DESCENDING; + resultType = NHQO.RESULTS_AS_URI; + } + } + + options.sortingMode = sortingMode; + options.resultType = resultType; + options.includeHidden = !!aInput; + + // call load() on the tree manually + // instead of setting the place attribute in history-panel.xul + // otherwise, we will end up calling load() twice + gHistoryTree.load([query], options); +} + +window.addEventListener("SidebarFocused", + function() + gSearchBox.focus(), + false); diff --git a/browser/components/places/content/history-panel.xul b/browser/components/places/content/history-panel.xul new file mode 100644 index 000000000..bcc581a60 --- /dev/null +++ b/browser/components/places/content/history-panel.xul @@ -0,0 +1,92 @@ +<?xml version="1.0"?> <!-- -*- Mode: xml; indent-tabs-mode: nil; -*- --> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://browser/skin/places/places.css"?> + +<?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?> +<?xul-overlay href="chrome://browser/content/places/placesOverlay.xul"?> + +<!DOCTYPE page [ +<!ENTITY % placesDTD SYSTEM "chrome://browser/locale/places/places.dtd"> +%placesDTD; +]> + +<!-- we need to keep id="history-panel" for upgrade and switching + between versions of the browser --> + +<page id="history-panel" orient="vertical" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="HistorySidebarInit();" + onunload="SidebarUtils.setMouseoverURL('');"> + + <script type="application/javascript" + src="chrome://browser/content/bookmarks/sidebarUtils.js"/> + <script type="application/javascript" + src="chrome://browser/content/places/history-panel.js"/> + + <commandset id="editMenuCommands"/> + <commandset id="placesCommands"/> + + <keyset id="editMenuKeys"> + </keyset> + + <!-- required to overlay the context menu --> + <menupopup id="placesContext"/> + + <!-- Bookmarks and history tooltip --> + <tooltip id="bhTooltip"/> + + <hbox id="sidebar-search-container" align="center"> + <label id="sidebar-search-label" + value="&find.label;" accesskey="&find.accesskey;" + control="search-box"/> + <textbox id="search-box" flex="1" type="search" class="compact" + aria-controls="historyTree" + oncommand="searchHistory(this.value);"/> + <button id="viewButton" style="min-width:0px !important;" type="menu" + label="&view.label;" accesskey="&view.accesskey;" selectedsort="day" + persist="selectedsort"> + <menupopup> + <menuitem id="bydayandsite" label="&byDayAndSite.label;" + accesskey="&byDayAndSite.accesskey;" type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'dayandsite'); GroupBy('dayandsite');"/> + <menuitem id="bysite" label="&bySite.label;" + accesskey="&bySite.accesskey;" type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'site'); GroupBy('site');"/> + <menuitem id="byday" label="&byDate.label;" + accesskey="&byDate.accesskey;" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'day'); GroupBy('day');"/> + <menuitem id="byvisited" label="&byMostVisited.label;" + accesskey="&byMostVisited.accesskey;" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'visited'); GroupBy('visited');"/> + <menuitem id="bylastvisited" label="&byLastVisited.label;" + accesskey="&byLastVisited.accesskey;" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'lastvisited'); GroupBy('lastvisited');"/> + </menupopup> + </button> + </hbox> + + <tree id="historyTree" + class="sidebar-placesTree" + flex="1" + type="places" + context="placesContext" + hidecolumnpicker="true" + onkeypress="SidebarUtils.handleTreeKeyPress(event);" + onclick="SidebarUtils.handleTreeClick(this, event, true);" + onmousemove="SidebarUtils.handleTreeMouseMove(event);" + onmouseout="SidebarUtils.setMouseoverURL('');"> + <treecols> + <treecol id="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren class="sidebar-placesTreechildren" flex="1" tooltip="bhTooltip"/> + </tree> +</page> diff --git a/browser/components/places/content/menu.xml b/browser/components/places/content/menu.xml new file mode 100644 index 000000000..0fed40966 --- /dev/null +++ b/browser/components/places/content/menu.xml @@ -0,0 +1,475 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<bindings id="placesMenuBindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <binding id="places-popup-base" + extends="chrome://global/content/bindings/popup.xml#popup"> + <content> + <xul:hbox flex="1"> + <xul:vbox class="menupopup-drop-indicator-bar" hidden="true"> + <xul:image class="menupopup-drop-indicator" mousethrough="always"/> + </xul:vbox> + <xul:arrowscrollbox class="popup-internal-box" flex="1" orient="vertical" + smoothscroll="false"> + <children/> + </xul:arrowscrollbox> + </xul:hbox> + </content> + + <implementation> + + <field name="_indicatorBar"> + document.getAnonymousElementByAttribute(this, "class", + "menupopup-drop-indicator-bar"); + </field> + + <field name="_scrollBox"> + document.getAnonymousElementByAttribute(this, "class", + "popup-internal-box"); + </field> + + <!-- This is the view that manage the popup --> + <field name="_rootView">PlacesUIUtils.getViewForNode(this);</field> + + <!-- Check if we should hide the drop indicator for the target --> + <method name="_hideDropIndicator"> + <parameter name="aEvent"/> + <body><![CDATA[ + let target = aEvent.target; + + // Don't draw the drop indicator outside of markers. + // The markers are hidden, since otherwise sometimes popups acquire + // scrollboxes on OS X, so we can't use them directly. + let firstChildTop = this._startMarker.nextSibling.boxObject.y; + let lastChildBottom = this._endMarker.previousSibling.boxObject.y + + this._endMarker.previousSibling.boxObject.height; + let betweenMarkers = target.boxObject.y >= firstChildTop || + target.boxObject.y <= lastChildBottom; + + // Hide the dropmarker if current node is not a Places node. + return !(target && target._placesNode && betweenMarkers); + ]]></body> + </method> + + <!-- This function returns information about where to drop when + dragging over this popup insertion point --> + <method name="_getDropPoint"> + <parameter name="aEvent"/> + <body><![CDATA[ + // Can't drop if the menu isn't a folder + let resultNode = this._placesNode; + + if (!PlacesUtils.nodeIsFolder(resultNode) || + PlacesControllerDragHelper.disallowInsertion(resultNode)) { + return null; + } + + var dropPoint = { ip: null, folderElt: null }; + + // The element we are dragging over + let elt = aEvent.target; + if (elt.localName == "menupopup") + elt = elt.parentNode; + + // Calculate positions taking care of arrowscrollbox + let eventY = aEvent.layerY; + let scrollbox = this._scrollBox; + let scrollboxOffset = scrollbox.scrollBoxObject.y - + (scrollbox.boxObject.y - this.boxObject.y); + let eltY = elt.boxObject.y - scrollboxOffset; + let eltHeight = elt.boxObject.height; + + if (!elt._placesNode) { + // If we are dragging over a non places node drop at the end. + dropPoint.ip = new InsertionPoint( + PlacesUtils.getConcreteItemId(resultNode), + -1, + Ci.nsITreeView.DROP_ON); + // We can set folderElt if we are dropping over a static menu that + // has an internal placespopup. + let isMenu = elt.localName == "menu" || + (elt.localName == "toolbarbutton" && + elt.getAttribute("type") == "menu"); + if (isMenu && elt.lastChild && + elt.lastChild.hasAttribute("placespopup")) + dropPoint.folderElt = elt; + return dropPoint; + } + if ((PlacesUtils.nodeIsFolder(elt._placesNode) && + !PlacesUIUtils.isContentsReadOnly(elt._placesNode)) || + PlacesUtils.nodeIsTagQuery(elt._placesNode)) { + // This is a folder or a tag container. + if (eventY - eltY < eltHeight * 0.20) { + // If mouse is in the top part of the element, drop above folder. + dropPoint.ip = new InsertionPoint( + PlacesUtils.getConcreteItemId(resultNode), + -1, + Ci.nsITreeView.DROP_BEFORE, + PlacesUtils.nodeIsTagQuery(elt._placesNode), + elt._placesNode.itemId); + return dropPoint; + } + else if (eventY - eltY < eltHeight * 0.80) { + // If mouse is in the middle of the element, drop inside folder. + dropPoint.ip = new InsertionPoint( + PlacesUtils.getConcreteItemId(elt._placesNode), + -1, + Ci.nsITreeView.DROP_ON, + PlacesUtils.nodeIsTagQuery(elt._placesNode)); + dropPoint.folderElt = elt; + return dropPoint; + } + } + else if (eventY - eltY <= eltHeight / 2) { + // This is a non-folder node or a readonly folder. + // If the mouse is above the middle, drop above this item. + dropPoint.ip = new InsertionPoint( + PlacesUtils.getConcreteItemId(resultNode), + -1, + Ci.nsITreeView.DROP_BEFORE, + PlacesUtils.nodeIsTagQuery(elt._placesNode), + elt._placesNode.itemId); + return dropPoint; + } + + // Drop below the item. + dropPoint.ip = new InsertionPoint( + PlacesUtils.getConcreteItemId(resultNode), + -1, + Ci.nsITreeView.DROP_AFTER, + PlacesUtils.nodeIsTagQuery(elt._placesNode), + elt._placesNode.itemId); + return dropPoint; + ]]></body> + </method> + + <!-- Sub-menus should be opened when the mouse drags over them, and closed + when the mouse drags off. The overFolder object manages opening and + closing of folders when the mouse hovers. --> + <field name="_overFolder"><![CDATA[({ + _self: this, + _folder: {elt: null, + openTimer: null, + hoverTime: 350, + closeTimer: null}, + _closeMenuTimer: null, + + get elt() { + return this._folder.elt; + }, + set elt(val) { + return this._folder.elt = val; + }, + + get openTimer() { + return this._folder.openTimer; + }, + set openTimer(val) { + return this._folder.openTimer = val; + }, + + get hoverTime() { + return this._folder.hoverTime; + }, + set hoverTime(val) { + return this._folder.hoverTime = val; + }, + + get closeTimer() { + return this._folder.closeTimer; + }, + set closeTimer(val) { + return this._folder.closeTimer = val; + }, + + get closeMenuTimer() { + return this._closeMenuTimer; + }, + set closeMenuTimer(val) { + return this._closeMenuTimer = val; + }, + + setTimer: function OF__setTimer(aTime) { + var timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(this, aTime, timer.TYPE_ONE_SHOT); + return timer; + }, + + notify: function OF__notify(aTimer) { + // Function to process all timer notifications. + + if (aTimer == this._folder.openTimer) { + // Timer to open a submenu that's being dragged over. + this._folder.elt.lastChild.setAttribute("autoopened", "true"); + this._folder.elt.lastChild.showPopup(this._folder.elt); + this._folder.openTimer = null; + } + + else if (aTimer == this._folder.closeTimer) { + // Timer to close a submenu that's been dragged off of. + // Only close the submenu if the mouse isn't being dragged over any + // of its child menus. + var draggingOverChild = PlacesControllerDragHelper + .draggingOverChildNode(this._folder.elt); + if (draggingOverChild) + this._folder.elt = null; + this.clear(); + + // Close any parent folders which aren't being dragged over. + // (This is necessary because of the above code that keeps a folder + // open while its children are being dragged over.) + if (!draggingOverChild) + this.closeParentMenus(); + } + + else if (aTimer == this.closeMenuTimer) { + // Timer to close this menu after the drag exit. + var popup = this._self; + // if we are no more dragging we can leave the menu open to allow + // for better D&D bookmark organization + if (PlacesControllerDragHelper.getSession() && + !PlacesControllerDragHelper.draggingOverChildNode(popup.parentNode)) { + popup.hidePopup(); + // Close any parent menus that aren't being dragged over; + // otherwise they'll stay open because they couldn't close + // while this menu was being dragged over. + this.closeParentMenus(); + } + this._closeMenuTimer = null; + } + }, + + // Helper function to close all parent menus of this menu, + // as long as none of the parent's children are currently being + // dragged over. + closeParentMenus: function OF__closeParentMenus() { + var popup = this._self; + var parent = popup.parentNode; + while (parent) { + if (parent.localName == "menupopup" && parent._placesNode) { + if (PlacesControllerDragHelper.draggingOverChildNode(parent.parentNode)) + break; + parent.hidePopup(); + } + parent = parent.parentNode; + } + }, + + // The mouse is no longer dragging over the stored menubutton. + // Close the menubutton, clear out drag styles, and clear all + // timers for opening/closing it. + clear: function OF__clear() { + if (this._folder.elt && this._folder.elt.lastChild) { + if (!this._folder.elt.lastChild.hasAttribute("dragover")) + this._folder.elt.lastChild.hidePopup(); + // remove menuactive style + this._folder.elt.removeAttribute("_moz-menuactive"); + this._folder.elt = null; + } + if (this._folder.openTimer) { + this._folder.openTimer.cancel(); + this._folder.openTimer = null; + } + if (this._folder.closeTimer) { + this._folder.closeTimer.cancel(); + this._folder.closeTimer = null; + } + } + })]]></field> + + <method name="_cleanupDragDetails"> + <body><![CDATA[ + // Called on dragend and drop. + PlacesControllerDragHelper.currentDropTarget = null; + this._rootView._draggedElt = null; + this.removeAttribute("dragover"); + this.removeAttribute("dragstart"); + this._indicatorBar.hidden = true; + ]]></body> + </method> + + </implementation> + + <handlers> + <handler event="DOMMenuItemActive"><![CDATA[ + let elt = event.target; + if (elt.parentNode != this) + return; + + if (window.XULBrowserWindow) { + let elt = event.target; + let placesNode = elt._placesNode; + + var linkURI; + if (placesNode && PlacesUtils.nodeIsURI(placesNode)) + linkURI = placesNode.uri; + else if (elt.hasAttribute("targetURI")) + linkURI = elt.getAttribute("targetURI"); + + if (linkURI) + window.XULBrowserWindow.setOverLink(linkURI, null); + } + ]]></handler> + + <handler event="DOMMenuItemInactive"><![CDATA[ + let elt = event.target; + if (elt.parentNode != this) + return; + + if (window.XULBrowserWindow) + window.XULBrowserWindow.setOverLink("", null); + ]]></handler> + + <handler event="dragstart"><![CDATA[ + if (!event.target._placesNode) + return; + + let draggedElt = event.target._placesNode; + + // Force a copy action if parent node is a query or we are dragging a + // not-removable node. + if (!PlacesControllerDragHelper.canMoveNode(draggedElt)) + event.dataTransfer.effectAllowed = "copyLink"; + + // Activate the view and cache the dragged element. + this._rootView._draggedElt = draggedElt; + this._rootView.controller.setDataTransfer(event); + this.setAttribute("dragstart", "true"); + event.stopPropagation(); + ]]></handler> + + <handler event="drop"><![CDATA[ + PlacesControllerDragHelper.currentDropTarget = event.target; + + let dropPoint = this._getDropPoint(event); + if (dropPoint && dropPoint.ip) { + PlacesControllerDragHelper.onDrop(dropPoint.ip, event.dataTransfer); + event.preventDefault(); + } + + this._cleanupDragDetails(); + event.stopPropagation(); + ]]></handler> + + <handler event="dragover"><![CDATA[ + PlacesControllerDragHelper.currentDropTarget = event.target; + let dt = event.dataTransfer; + + let dropPoint = this._getDropPoint(event); + if (!dropPoint || !dropPoint.ip || + !PlacesControllerDragHelper.canDrop(dropPoint.ip, dt)) { + this._indicatorBar.hidden = true; + event.stopPropagation(); + return; + } + + // Mark this popup as being dragged over. + this.setAttribute("dragover", "true"); + + if (dropPoint.folderElt) { + // We are dragging over a folder. + // _overFolder should take the care of opening it on a timer. + if (this._overFolder.elt && + this._overFolder.elt != dropPoint.folderElt) { + // We are dragging over a new folder, let's clear old values + this._overFolder.clear(); + } + if (!this._overFolder.elt) { + this._overFolder.elt = dropPoint.folderElt; + // Create the timer to open this folder. + this._overFolder.openTimer = this._overFolder + .setTimer(this._overFolder.hoverTime); + } + // Since we are dropping into a folder set the corresponding style. + dropPoint.folderElt.setAttribute("_moz-menuactive", true); + } + else { + // We are not dragging over a folder. + // Clear out old _overFolder information. + this._overFolder.clear(); + } + + // Autoscroll the popup strip if we drag over the scroll buttons. + let anonid = event.originalTarget.getAttribute('anonid'); + let scrollDir = anonid == "scrollbutton-up" ? -1 : + anonid == "scrollbutton-down" ? 1 : 0; + if (scrollDir != 0) { + this._scrollBox.scrollByIndex(scrollDir, false); + } + + // Check if we should hide the drop indicator for this target. + if (dropPoint.folderElt || this._hideDropIndicator(event)) { + this._indicatorBar.hidden = true; + event.preventDefault(); + event.stopPropagation(); + return; + } + + // We should display the drop indicator relative to the arrowscrollbox. + let sbo = this._scrollBox.scrollBoxObject; + let newMarginTop = 0; + if (scrollDir == 0) { + let elt = this.firstChild; + while (elt && event.screenY > elt.boxObject.screenY + + elt.boxObject.height / 2) + elt = elt.nextSibling; + newMarginTop = elt ? elt.boxObject.screenY - sbo.screenY : + sbo.height; + } + else if (scrollDir == 1) + newMarginTop = sbo.height; + + // Set the new marginTop based on arrowscrollbox. + newMarginTop += sbo.y - this._scrollBox.boxObject.y; + this._indicatorBar.firstChild.style.marginTop = newMarginTop + "px"; + this._indicatorBar.hidden = false; + + event.preventDefault(); + event.stopPropagation(); + ]]></handler> + + <handler event="dragexit"><![CDATA[ + PlacesControllerDragHelper.currentDropTarget = null; + this.removeAttribute("dragover"); + + // If we have not moved to a valid new target clear the drop indicator + // this happens when moving out of the popup. + let target = event.relatedTarget; + if (!target) + this._indicatorBar.hidden = true; + + // Close any folder being hovered over + if (this._overFolder.elt) { + this._overFolder.closeTimer = this._overFolder + .setTimer(this._overFolder.hoverTime); + } + + // The autoopened attribute is set when this folder was automatically + // opened after the user dragged over it. If this attribute is set, + // auto-close the folder on drag exit. + // We should also try to close this popup if the drag has started + // from here, the timer will check if we are dragging over a child. + if (this.hasAttribute("autoopened") || + this.hasAttribute("dragstart")) { + this._overFolder.closeMenuTimer = this._overFolder + .setTimer(this._overFolder.hoverTime); + } + + event.stopPropagation(); + ]]></handler> + + <handler event="dragend"><![CDATA[ + this._cleanupDragDetails(); + ]]></handler> + + </handlers> + </binding> +</bindings> diff --git a/browser/components/places/content/moveBookmarks.js b/browser/components/places/content/moveBookmarks.js new file mode 100644 index 000000000..6b1abd483 --- /dev/null +++ b/browser/components/places/content/moveBookmarks.js @@ -0,0 +1,54 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var gMoveBookmarksDialog = { + _nodes: null, + + _foldersTree: null, + get foldersTree() { + if (!this._foldersTree) + this._foldersTree = document.getElementById("foldersTree"); + + return this._foldersTree; + }, + + init: function() { + this._nodes = window.arguments[0]; + + this.foldersTree.place = + "place:excludeItems=1&excludeQueries=1&excludeReadOnlyFolders=1&folder=" + + PlacesUIUtils.allBookmarksFolderId; + }, + + onOK: function(aEvent) { + var selectedNode = this.foldersTree.selectedNode; + NS_ASSERT(selectedNode, + "selectedNode must be set in a single-selection tree with initial selection set"); + var selectedFolderID = PlacesUtils.getConcreteItemId(selectedNode); + + var transactions = []; + for (var i=0; i < this._nodes.length; i++) { + // Nothing to do if the node is already under the selected folder + if (this._nodes[i].parent.itemId == selectedFolderID) + continue; + + let txn = new PlacesMoveItemTransaction(this._nodes[i].itemId, + selectedFolderID, + PlacesUtils.bookmarks.DEFAULT_INDEX); + transactions.push(txn); + } + + if (transactions.length != 0) { + let txn = new PlacesAggregatedTransaction("Move Items", transactions); + PlacesUtils.transactionManager.doTransaction(txn); + } + }, + + newFolder: function() { + // The command is disabled when the tree is not focused + this.foldersTree.focus(); + goDoCommand("placesCmd_new:folder"); + } +}; diff --git a/browser/components/places/content/moveBookmarks.xul b/browser/components/places/content/moveBookmarks.xul new file mode 100644 index 000000000..b6e75f3da --- /dev/null +++ b/browser/components/places/content/moveBookmarks.xul @@ -0,0 +1,53 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/"?> +<?xml-stylesheet href="chrome://browser/skin/places/places.css"?> +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> + +<?xul-overlay href="chrome://browser/content/places/placesOverlay.xul"?> + +<!DOCTYPE window [ + <!ENTITY % moveBookmarksDTD SYSTEM "chrome://browser/locale/places/moveBookmarks.dtd"> + %moveBookmarksDTD; +]> + +<dialog id="moveBookmarkDialog" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + ondialogaccept="return gMoveBookmarksDialog.onOK(event);" + title="&window.title;" + onload="gMoveBookmarksDialog.init();" + style="&window.style;" + screenX="24" + screenY="24" + persist="screenX screenY width height"> + + <script type="application/javascript" + src="chrome://browser/content/places/moveBookmarks.js"/> + + <hbox flex="1"> + <label id="movetolabel" value="&moveTo.label;" control="foldersTree"/> + <hbox flex="1"> + <tree id="foldersTree" + class="placesTree" + flex="1" + type="places" + seltype="single" + hidecolumnpicker="true"> + <treecols> + <treecol id="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren id="placesListChildren" view="placesList" flex="1"/> + </tree> + <vbox> + <button id="newFolderButton" + label="&newFolderButton.label;" + accesskey="&newFolderButton.accesskey;" + oncommand="gMoveBookmarksDialog.newFolder();"/> + </vbox> + </hbox> + </hbox> +</dialog> diff --git a/browser/components/places/content/organizer.css b/browser/components/places/content/organizer.css new file mode 100644 index 000000000..47b1832c1 --- /dev/null +++ b/browser/components/places/content/organizer.css @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#searchFilter { + width: 23em; +} diff --git a/browser/components/places/content/places.css b/browser/components/places/content/places.css new file mode 100644 index 000000000..5151cca82 --- /dev/null +++ b/browser/components/places/content/places.css @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +tree[type="places"] { + -moz-binding: url("chrome://browser/content/places/tree.xml#places-tree"); +} + +.toolbar-drop-indicator { + position: relative; + z-index: 1; +} + +menupopup[placespopup="true"] { + -moz-binding: url("chrome://browser/content/places/menu.xml#places-popup-base"); +} diff --git a/browser/components/places/content/places.js b/browser/components/places/content/places.js new file mode 100644 index 000000000..a2339adfe --- /dev/null +++ b/browser/components/places/content/places.js @@ -0,0 +1,1532 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "BookmarkJSONUtils", + "resource://gre/modules/BookmarkJSONUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesBackups", + "resource://gre/modules/PlacesBackups.jsm"); + +const RESTORE_FILEPICKER_FILTER_EXT = "*.json;*.jsonlz4"; + +var PlacesOrganizer = { + _places: null, + + // IDs of fields from editBookmarkOverlay that should be hidden when infoBox + // is minimal. IDs should be kept in sync with the IDs of the elements + // observing additionalInfoBroadcaster. + _additionalInfoFields: [ + "editBMPanel_descriptionRow", + "editBMPanel_loadInSidebarCheckbox", + "editBMPanel_keywordRow", + ], + + _initFolderTree: function() { + var leftPaneRoot = PlacesUIUtils.leftPaneFolderId; + this._places.place = "place:excludeItems=1&expandQueries=0&folder=" + leftPaneRoot; + }, + + selectLeftPaneQuery: function(aQueryName) { + var itemId = PlacesUIUtils.leftPaneQueries[aQueryName]; + this._places.selectItems([itemId]); + // Forcefully expand all-bookmarks + if (aQueryName == "AllBookmarks" || aQueryName == "History") + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + }, + + init: function() { + ContentArea.init(); + + this._places = document.getElementById("placesList"); + this._initFolderTree(); + + var leftPaneSelection = "AllBookmarks"; // default to all-bookmarks + if (window.arguments && window.arguments[0]) + leftPaneSelection = window.arguments[0]; + + this.selectLeftPaneQuery(leftPaneSelection); + if (leftPaneSelection == "History") { + let historyNode = this._places.selectedNode; + if (historyNode.childCount > 0) + this._places.selectNode(historyNode.getChild(0)); + } + // clear the back-stack + this._backHistory.splice(0, this._backHistory.length); + document.getElementById("OrganizerCommand:Back").setAttribute("disabled", true); + + // Set up the search UI. + PlacesSearchBox.init(); + + window.addEventListener("AppCommand", this, true); + + // remove the "Properties" context-menu item, we've our own details pane + document.getElementById("placesContext") + .removeChild(document.getElementById("placesContext_show:info")); + + ContentArea.focus(); + }, + + QueryInterface: function(aIID) { + if (aIID.equals(Components.interfaces.nsIDOMEventListener) || + aIID.equals(Components.interfaces.nsISupports)) + return this; + + throw new Components.Exception("", Components.results.NS_NOINTERFACE); + }, + + handleEvent: function(aEvent) { + if (aEvent.type != "AppCommand") + return; + + aEvent.stopPropagation(); + switch (aEvent.command) { + case "Back": + if (this._backHistory.length > 0) + this.back(); + break; + case "Forward": + if (this._forwardHistory.length > 0) + this.forward(); + break; + case "Search": + PlacesSearchBox.findAll(); + break; + } + }, + + destroy: function() { + }, + + _location: null, + get location() { + return this._location; + }, + + set location(aLocation) { + if (!aLocation || this._location == aLocation) + return aLocation; + + if (this.location) { + this._backHistory.unshift(this.location); + this._forwardHistory.splice(0, this._forwardHistory.length); + } + + this._location = aLocation; + this._places.selectPlaceURI(aLocation); + + if (!this._places.hasSelection) { + // If no node was found for the given place: uri, just load it directly + ContentArea.currentPlace = aLocation; + } + this.updateDetailsPane(); + + // update navigation commands + if (this._backHistory.length == 0) + document.getElementById("OrganizerCommand:Back").setAttribute("disabled", true); + else + document.getElementById("OrganizerCommand:Back").removeAttribute("disabled"); + if (this._forwardHistory.length == 0) + document.getElementById("OrganizerCommand:Forward").setAttribute("disabled", true); + else + document.getElementById("OrganizerCommand:Forward").removeAttribute("disabled"); + + return aLocation; + }, + + _backHistory: [], + _forwardHistory: [], + + back: function() { + this._forwardHistory.unshift(this.location); + var historyEntry = this._backHistory.shift(); + this._location = null; + this.location = historyEntry; + }, + forward: function() { + this._backHistory.unshift(this.location); + var historyEntry = this._forwardHistory.shift(); + this._location = null; + this.location = historyEntry; + }, + + /** + * Called when a place folder is selected in the left pane. + * @param resetSearchBox + * true if the search box should also be reset, false otherwise. + * The search box should be reset when a new folder in the left + * pane is selected; the search scope and text need to be cleared in + * preparation for the new folder. Note that if the user manually + * resets the search box, either by clicking its reset button or by + * deleting its text, this will be false. + */ + _cachedLeftPaneSelectedURI: null, + onPlaceSelected: function(resetSearchBox) { + // Don't change the right-hand pane contents when there's no selection. + if (!this._places.hasSelection) + return; + + var node = this._places.selectedNode; + var queries = PlacesUtils.asQuery(node).getQueries(); + + // Items are only excluded on the left pane. + var options = node.queryOptions.clone(); + options.excludeItems = false; + var placeURI = PlacesUtils.history.queriesToQueryString(queries, + queries.length, + options); + + // If either the place of the content tree in the right pane has changed or + // the user cleared the search box, update the place, hide the search UI, + // and update the back/forward buttons by setting location. + if (ContentArea.currentPlace != placeURI || !resetSearchBox) { + ContentArea.currentPlace = placeURI; + PlacesSearchBox.hideSearchUI(); + this.location = node.uri; + } + + // Update the selected folder title where it appears in the UI: the folder + // scope button, and the search box emptytext. + // They must be updated even if the selection hasn't changed -- + // specifically when node's title changes. In that case a selection event + // is generated, this method is called, but the selection does not change. + var folderButton = document.getElementById("scopeBarFolder"); + var folderTitle = node.title || folderButton.getAttribute("emptytitle"); + folderButton.setAttribute("label", folderTitle); + if (PlacesSearchBox.filterCollection == "collection") + PlacesSearchBox.updateCollectionTitle(folderTitle); + + // When we invalidate a container we use suppressSelectionEvent, when it is + // unset a select event is fired, in many cases the selection did not really + // change, so we should check for it, and return early in such a case. Note + // that we cannot return any earlier than this point, because when + // !resetSearchBox, we need to update location and hide the UI as above, + // even though the selection has not changed. + if (node.uri == this._cachedLeftPaneSelectedURI) + return; + this._cachedLeftPaneSelectedURI = node.uri; + + // At this point, resetSearchBox is true, because the left pane selection + // has changed; otherwise we would have returned earlier. + + PlacesSearchBox.searchFilter.reset(); + this._setSearchScopeForNode(node); + this.updateDetailsPane(); + }, + + /** + * Sets the search scope based on aNode's properties. + * @param aNode + * the node to set up scope from + */ + _setSearchScopeForNode: function(aNode) { + let itemId = aNode.itemId; + + // Set default buttons status. + let bookmarksButton = document.getElementById("scopeBarAll"); + bookmarksButton.hidden = false; + let downloadsButton = document.getElementById("scopeBarDownloads"); + downloadsButton.hidden = true; + + if (PlacesUtils.nodeIsHistoryContainer(aNode) || + itemId == PlacesUIUtils.leftPaneQueries["History"]) { + PlacesQueryBuilder.setScope("history"); + } + else if (itemId == PlacesUIUtils.leftPaneQueries["Downloads"]) { + downloadsButton.hidden = false; + bookmarksButton.hidden = true; + PlacesQueryBuilder.setScope("downloads"); + } + else { + // Default to All Bookmarks for all other nodes, per bug 469437. + PlacesQueryBuilder.setScope("bookmarks"); + } + + // Enable or disable the folder scope button. + let folderButton = document.getElementById("scopeBarFolder"); + folderButton.hidden = !PlacesUtils.nodeIsFolder(aNode) || + itemId == PlacesUIUtils.allBookmarksFolderId; + }, + + /** + * Handle clicks on the places list. + * Single Left click, right click or modified click do not result in any + * special action, since they're related to selection. + * @param aEvent + * The mouse event. + */ + onPlacesListClick: function(aEvent) { + // Only handle clicks on tree children. + if (aEvent.target.localName != "treechildren") + return; + + let node = this._places.selectedNode; + if (node) { + let middleClick = aEvent.button == 1 && aEvent.detail == 1; + if (middleClick && PlacesUtils.nodeIsContainer(node)) { + // The command execution function will take care of seeing if the + // selection is a folder or a different container type, and will + // load its contents in tabs. + PlacesUIUtils.openContainerNodeInTabs(selectedNode, aEvent, this._places); + } + } + }, + + /** + * Handle focus changes on the places list and the current content view. + */ + updateDetailsPane: function() { + if (!ContentArea.currentViewOptions.showDetailsPane) + return; + let view = PlacesUIUtils.getViewForNode(document.activeElement); + if (view) { + let selectedNodes = view.selectedNode ? + [view.selectedNode] : view.selectedNodes; + this._fillDetailsPane(selectedNodes); + } + }, + + openFlatContainer: function(aContainer) { + if (aContainer.itemId != -1) + this._places.selectItems([aContainer.itemId]); + else if (PlacesUtils.nodeIsQuery(aContainer)) + this._places.selectPlaceURI(aContainer.uri); + }, + + /** + * Returns the options associated with the query currently loaded in the + * main places pane. + */ + getCurrentOptions: function() { + return PlacesUtils.asQuery(ContentArea.currentView.result.root).queryOptions; + }, + + /** + * Returns the queries associated with the query currently loaded in the + * main places pane. + */ + getCurrentQueries: function() { + return PlacesUtils.asQuery(ContentArea.currentView.result.root).getQueries(); + }, + + /** + * Open a file-picker and import the selected file into the bookmarks store + */ + importFromFile: function() { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel && fp.fileURL) { + Components.utils.import("resource://gre/modules/BookmarkHTMLUtils.jsm"); + BookmarkHTMLUtils.importFromURL(fp.fileURL.spec, false) + .then(null, Components.utils.reportError); + } + }; + + fp.init(window, PlacesUIUtils.getString("SelectImport"), + Ci.nsIFilePicker.modeOpen); + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + fp.open(fpCallback); + }, + + /** + * Allows simple exporting of bookmarks. + */ + exportBookmarks: function() { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + Components.utils.import("resource://gre/modules/BookmarkHTMLUtils.jsm"); + BookmarkHTMLUtils.exportToFile(fp.file.path) + .then(null, Components.utils.reportError); + } + }; + + fp.init(window, PlacesUIUtils.getString("EnterExport"), + Ci.nsIFilePicker.modeSave); + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + fp.defaultString = "bookmarks.html"; + fp.open(fpCallback); + }, + + /** + * Populates the restore menu with the dates of the backups available. + */ + populateRestoreMenu: function() { + let restorePopup = document.getElementById("fileRestorePopup"); + + let dateSvc = Cc["@mozilla.org/intl/scriptabledateformat;1"]. + getService(Ci.nsIScriptableDateFormat); + + // Remove existing menu items. Last item is the restoreFromFile item. + while (restorePopup.childNodes.length > 1) + restorePopup.removeChild(restorePopup.firstChild); + + Task.spawn(function() { + let backupFiles = yield PlacesBackups.getBackupFiles(); + if (backupFiles.length == 0) + return; + + // Populate menu with backups. + for (let i = 0; i < backupFiles.length; i++) { + let fileSize = (yield OS.File.stat(backupFiles[i])).size; + let [size, unit] = DownloadUtils.convertByteUnits(fileSize); + let sizeString = PlacesUtils.getFormattedString("backupFileSizeText", + [size, unit]); + let sizeInfo; + let bookmarkCount = PlacesBackups.getBookmarkCountForFile(backupFiles[i]); + if (bookmarkCount != null) { + sizeInfo = " (" + sizeString + " - " + + PlacesUIUtils.getPluralString("detailsPane.itemsCountLabel", + bookmarkCount, + [bookmarkCount]) + + ")"; + } else { + sizeInfo = " (" + sizeString + ")"; + } + + let backupDate = PlacesBackups.getDateForFile(backupFiles[i]); + let m = restorePopup.insertBefore(document.createElement("menuitem"), + document.getElementById("restoreFromFile")); + m.setAttribute("label", + dateSvc.FormatDate("", + Ci.nsIScriptableDateFormat.dateFormatLong, + backupDate.getFullYear(), + backupDate.getMonth() + 1, + backupDate.getDate()) + + sizeInfo); + m.setAttribute("value", OS.Path.basename(backupFiles[i])); + m.setAttribute("oncommand", + "PlacesOrganizer.onRestoreMenuItemClick(this);"); + } + + // Add the restoreFromFile item. + restorePopup.insertBefore(document.createElement("menuseparator"), + document.getElementById("restoreFromFile")); + }); + }, + + /** + * Called when a menuitem is selected from the restore menu. + */ + onRestoreMenuItemClick: function(aMenuItem) { + Task.spawn(function() { + let backupName = aMenuItem.getAttribute("value"); + let backupFilePaths = yield PlacesBackups.getBackupFiles(); + for (let backupFilePath of backupFilePaths) { + if (OS.Path.basename(backupFilePath) == backupName) { + PlacesOrganizer.restoreBookmarksFromFile(new FileUtils.File(backupFilePath)); + break; + } + } + }); + }, + + /** + * Called when 'Choose File...' is selected from the restore menu. + * Prompts for a file and restores bookmarks to those in the file. + */ + onRestoreBookmarksFromFile: function() { + let dirSvc = Cc["@mozilla.org/file/directory_service;1"]. + getService(Ci.nsIProperties); + let backupsDir = dirSvc.get("Desk", Ci.nsILocalFile); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + this.restoreBookmarksFromFile(fp.file); + } + }.bind(this); + + fp.init(window, PlacesUIUtils.getString("bookmarksRestoreTitle"), + Ci.nsIFilePicker.modeOpen); + fp.appendFilter(PlacesUIUtils.getString("bookmarksRestoreFilterName"), + RESTORE_FILEPICKER_FILTER_EXT); + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.displayDirectory = backupsDir; + fp.open(fpCallback); + }, + + /** + * Restores bookmarks from a JSON file. + */ + restoreBookmarksFromFile: function(aFile) { + // check file extension + let filePath = aFile.path; + if (!filePath.toLowerCase().endsWith("json") && + !filePath.toLowerCase().endsWith("jsonlz4")) { + this._showErrorAlert(PlacesUIUtils.getString("bookmarksRestoreFormatError")); + return; + } + + // confirm ok to delete existing bookmarks + var prompts = Cc["@mozilla.org/embedcomp/prompt-service;1"]. + getService(Ci.nsIPromptService); + if (!prompts.confirm(null, + PlacesUIUtils.getString("bookmarksRestoreAlertTitle"), + PlacesUIUtils.getString("bookmarksRestoreAlert"))) + return; + + Task.spawn(function() { + try { + yield BookmarkJSONUtils.importFromFile(aFile.path, true); + } catch(ex) { + PlacesOrganizer._showErrorAlert(PlacesUIUtils.getString("bookmarksRestoreParseError")); + } + }); + }, + + _showErrorAlert: function(aMsg) { + var brandShortName = document.getElementById("brandStrings"). + getString("brandShortName"); + + Cc["@mozilla.org/embedcomp/prompt-service;1"]. + getService(Ci.nsIPromptService). + alert(window, brandShortName, aMsg); + }, + + /** + * Backup bookmarks to desktop, auto-generate a filename with a date. + * The file is a JSON serialization of bookmarks, tags and any annotations + * of those items. + */ + backupBookmarks: function() { + let dirSvc = Cc["@mozilla.org/file/directory_service;1"]. + getService(Ci.nsIProperties); + let backupsDir = dirSvc.get("Desk", Ci.nsILocalFile); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + BookmarkJSONUtils.exportToFile(fp.file.path); + } + }; + + fp.init(window, PlacesUIUtils.getString("bookmarksBackupTitle"), + Ci.nsIFilePicker.modeSave); + fp.appendFilter(PlacesUIUtils.getString("bookmarksRestoreFilterName"), + RESTORE_FILEPICKER_FILTER_EXT); + fp.defaultString = PlacesBackups.getFilenameForDate(); + fp.displayDirectory = backupsDir; + fp.open(fpCallback); + }, + + _paneDisabled: false, + _setDetailsFieldsDisabledState: + function(aDisabled) { + if (aDisabled) { + document.getElementById("paneElementsBroadcaster") + .setAttribute("disabled", "true"); + } + else { + document.getElementById("paneElementsBroadcaster") + .removeAttribute("disabled"); + } + }, + + _detectAndSetDetailsPaneMinimalState: + function(aNode) { + /** + * The details of simple folder-items (as opposed to livemarks) or the + * of livemark-children are not likely to fill the infoBox anyway, + * thus we remove the "More/Less" button and show all details. + * + * the wasminimal attribute here is used to persist the "more/less" + * state in a bookmark->folder->bookmark scenario. + */ + var infoBox = document.getElementById("infoBox"); + var infoBoxExpander = document.getElementById("infoBoxExpander"); + var infoBoxExpanderWrapper = document.getElementById("infoBoxExpanderWrapper"); + var additionalInfoBroadcaster = document.getElementById("additionalInfoBroadcaster"); + + if (!aNode) { + infoBoxExpanderWrapper.hidden = true; + return; + } + if (aNode.itemId != -1 && + PlacesUtils.nodeIsFolder(aNode) && !aNode._feedURI) { + if (infoBox.getAttribute("minimal") == "true") + infoBox.setAttribute("wasminimal", "true"); + infoBox.removeAttribute("minimal"); + infoBoxExpanderWrapper.hidden = true; + } + else { + if (infoBox.getAttribute("wasminimal") == "true") + infoBox.setAttribute("minimal", "true"); + infoBox.removeAttribute("wasminimal"); + infoBoxExpanderWrapper.hidden = + this._additionalInfoFields.every(function(id) + document.getElementById(id).collapsed); + } + additionalInfoBroadcaster.hidden = infoBox.getAttribute("minimal") == "true"; + }, + + // NOT YET USED + updateThumbnailProportions: function() { + var previewBox = document.getElementById("previewBox"); + var canvas = document.getElementById("itemThumbnail"); + var height = previewBox.boxObject.height; + var width = height * (screen.width / screen.height); + canvas.width = width; + canvas.height = height; + }, + + _fillDetailsPane: function(aNodeList) { + var infoBox = document.getElementById("infoBox"); + var detailsDeck = document.getElementById("detailsDeck"); + + // Make sure the infoBox UI is visible if we need to use it, we hide it + // below when we don't. + infoBox.hidden = false; + var aSelectedNode = aNodeList.length == 1 ? aNodeList[0] : null; + // If a textbox within a panel is focused, force-blur it so its contents + // are saved + if (gEditItemOverlay.itemId != -1) { + var focusedElement = document.commandDispatcher.focusedElement; + if ((focusedElement instanceof HTMLInputElement || + focusedElement instanceof HTMLTextAreaElement) && + /^editBMPanel.*/.test(focusedElement.parentNode.parentNode.id)) + focusedElement.blur(); + + // don't update the panel if we are already editing this node unless we're + // in multi-edit mode + if (aSelectedNode) { + var concreteId = PlacesUtils.getConcreteItemId(aSelectedNode); + var nodeIsSame = gEditItemOverlay.itemId == aSelectedNode.itemId || + gEditItemOverlay.itemId == concreteId || + (aSelectedNode.itemId == -1 && gEditItemOverlay.uri && + gEditItemOverlay.uri == aSelectedNode.uri); + if (nodeIsSame && detailsDeck.selectedIndex == 1 && + !gEditItemOverlay.multiEdit) + return; + } + } + + // Clean up the panel before initing it again. + gEditItemOverlay.uninitPanel(false); + + if (aSelectedNode && !PlacesUtils.nodeIsSeparator(aSelectedNode)) { + detailsDeck.selectedIndex = 1; + // Using the concrete itemId is arguably wrong. The bookmarks API + // does allow setting properties for folder shortcuts as well, but since + // the UI does not distinct between the couple, we better just show + // the concrete item properties for shortcuts to root nodes. + var concreteId = PlacesUtils.getConcreteItemId(aSelectedNode); + var isRootItem = concreteId != -1 && PlacesUtils.isRootItem(concreteId); + var readOnly = isRootItem || + aSelectedNode.parent.itemId == PlacesUIUtils.leftPaneFolderId; + var useConcreteId = isRootItem || + PlacesUtils.nodeIsTagQuery(aSelectedNode); + var itemId = -1; + if (concreteId != -1 && useConcreteId) + itemId = concreteId; + else if (aSelectedNode.itemId != -1) + itemId = aSelectedNode.itemId; + else + itemId = PlacesUtils._uri(aSelectedNode.uri); + + gEditItemOverlay.initPanel(itemId, { hiddenRows: ["folderPicker"] + , forceReadOnly: readOnly + , titleOverride: aSelectedNode.title + }); + + // Dynamically generated queries, like history date containers, have + // itemId !=0 and do not exist in history. For them the panel is + // read-only, but empty, since it can't get a valid title for the object. + // In such a case we force the title using the selectedNode one, for UI + // polishness. + if (aSelectedNode.itemId == -1 && + (PlacesUtils.nodeIsDay(aSelectedNode) || + PlacesUtils.nodeIsHost(aSelectedNode))) + gEditItemOverlay._element("namePicker").value = aSelectedNode.title; + + this._detectAndSetDetailsPaneMinimalState(aSelectedNode); + } + else if (!aSelectedNode && aNodeList[0]) { + var itemIds = []; + for (var i = 0; i < aNodeList.length; i++) { + if (!PlacesUtils.nodeIsBookmark(aNodeList[i]) && + !PlacesUtils.nodeIsURI(aNodeList[i])) { + detailsDeck.selectedIndex = 0; + var selectItemDesc = document.getElementById("selectItemDescription"); + var itemsCountLabel = document.getElementById("itemsCountText"); + selectItemDesc.hidden = false; + itemsCountLabel.value = + PlacesUIUtils.getPluralString("detailsPane.itemsCountLabel", + aNodeList.length, [aNodeList.length]); + infoBox.hidden = true; + return; + } + itemIds[i] = aNodeList[i].itemId != -1 ? aNodeList[i].itemId : + PlacesUtils._uri(aNodeList[i].uri); + } + detailsDeck.selectedIndex = 1; + gEditItemOverlay.initPanel(itemIds, + { hiddenRows: ["folderPicker", + "loadInSidebar", + "location", + "keyword", + "description", + "name"]}); + this._detectAndSetDetailsPaneMinimalState(aSelectedNode); + } + else { + detailsDeck.selectedIndex = 0; + infoBox.hidden = true; + let selectItemDesc = document.getElementById("selectItemDescription"); + let itemsCountLabel = document.getElementById("itemsCountText"); + let itemsCount = 0; + if (ContentArea.currentView.result) { + let rootNode = ContentArea.currentView.result.root; + if (rootNode.containerOpen) + itemsCount = rootNode.childCount; + } + if (itemsCount == 0) { + selectItemDesc.hidden = true; + itemsCountLabel.value = PlacesUIUtils.getString("detailsPane.noItems"); + } + else { + selectItemDesc.hidden = false; + itemsCountLabel.value = + PlacesUIUtils.getPluralString("detailsPane.itemsCountLabel", + itemsCount, [itemsCount]); + } + } + }, + + // NOT YET USED + _updateThumbnail: function() { + var bo = document.getElementById("previewBox").boxObject; + var width = bo.width; + var height = bo.height; + + var canvas = document.getElementById("itemThumbnail"); + var ctx = canvas.getContext('2d'); + var notAvailableText = canvas.getAttribute("notavailabletext"); + ctx.save(); + ctx.fillStyle = "-moz-Dialog"; + ctx.fillRect(0, 0, width, height); + ctx.translate(width/2, height/2); + + ctx.fillStyle = "GrayText"; + ctx.mozTextStyle = "12pt sans serif"; + var len = ctx.mozMeasureText(notAvailableText); + ctx.translate(-len/2,0); + ctx.mozDrawText(notAvailableText); + ctx.restore(); + }, + + toggleAdditionalInfoFields: function() { + var infoBox = document.getElementById("infoBox"); + var infoBoxExpander = document.getElementById("infoBoxExpander"); + var infoBoxExpanderLabel = document.getElementById("infoBoxExpanderLabel"); + var additionalInfoBroadcaster = document.getElementById("additionalInfoBroadcaster"); + + if (infoBox.getAttribute("minimal") == "true") { + infoBox.removeAttribute("minimal"); + infoBoxExpanderLabel.value = infoBoxExpanderLabel.getAttribute("lesslabel"); + infoBoxExpanderLabel.accessKey = infoBoxExpanderLabel.getAttribute("lessaccesskey"); + infoBoxExpander.className = "expander-up"; + additionalInfoBroadcaster.removeAttribute("hidden"); + } + else { + infoBox.setAttribute("minimal", "true"); + infoBoxExpanderLabel.value = infoBoxExpanderLabel.getAttribute("morelabel"); + infoBoxExpanderLabel.accessKey = infoBoxExpanderLabel.getAttribute("moreaccesskey"); + infoBoxExpander.className = "expander-down"; + additionalInfoBroadcaster.setAttribute("hidden", "true"); + } + }, + + /** + * Save the current search (or advanced query) to the bookmarks root. + */ + saveSearch: function() { + // Get the place: uri for the query. + // If the advanced query builder is showing, use that. + var options = this.getCurrentOptions(); + var queries = this.getCurrentQueries(); + + var placeSpec = PlacesUtils.history.queriesToQueryString(queries, + queries.length, + options); + var placeURI = Cc["@mozilla.org/network/io-service;1"]. + getService(Ci.nsIIOService). + newURI(placeSpec, null, null); + + // Prompt the user for a name for the query. + // XXX - using prompt service for now; will need to make + // a real dialog and localize when we're sure this is the UI we want. + var title = PlacesUIUtils.getString("saveSearch.title"); + var inputLabel = PlacesUIUtils.getString("saveSearch.inputLabel"); + var defaultText = PlacesUIUtils.getString("saveSearch.inputDefaultText"); + + var prompts = Cc["@mozilla.org/embedcomp/prompt-service;1"]. + getService(Ci.nsIPromptService); + var check = {value: false}; + var input = {value: defaultText}; + var save = prompts.prompt(null, title, inputLabel, input, null, check); + + // Don't add the query if the user cancels or clears the seach name. + if (!save || input.value == "") + return; + + // Add the place: uri as a bookmark under the bookmarks root. + var txn = new PlacesCreateBookmarkTransaction(placeURI, + PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + input.value); + PlacesUtils.transactionManager.doTransaction(txn); + + // select and load the new query + this._places.selectPlaceURI(placeSpec); + } +}; + +/** + * A set of utilities relating to search within Bookmarks and History. + */ +var PlacesSearchBox = { + + /** + * The Search text field + */ + get searchFilter() { + return document.getElementById("searchFilter"); + }, + + /** + * Folders to include when searching. + */ + _folders: [], + get folders() { + if (this._folders.length == 0) { + this._folders.push(PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.toolbarFolderId); + } + return this._folders; + }, + set folders(aFolders) { + this._folders = aFolders; + return aFolders; + }, + + /** + * Run a search for the specified text, over the collection specified by + * the dropdown arrow. The default is all bookmarks, but can be + * localized to the active collection. + * @param filterString + * The text to search for. + */ + search: function(filterString) { + var PO = PlacesOrganizer; + // If the user empties the search box manually, reset it and load all + // contents of the current scope. + // XXX this might be to jumpy, maybe should search for "", so results + // are ungrouped, and search box not reset + if (filterString == "") { + PO.onPlaceSelected(false); + return; + } + + let currentView = ContentArea.currentView; + let currentOptions = PO.getCurrentOptions(); + + // Search according to the current scope and folders, which were set by + // PQB_setScope() + switch (PlacesSearchBox.filterCollection) { + case "collection": + currentView.applyFilter(filterString, this.folders); + break; + case "bookmarks": + currentView.applyFilter(filterString, this.folders); + break; + case "history": + if (currentOptions.queryType != Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + var query = PlacesUtils.history.getNewQuery(); + query.searchTerms = filterString; + var options = currentOptions.clone(); + // Make sure we're getting uri results. + options.resultType = currentOptions.RESULTS_AS_URI; + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY; + options.includeHidden = true; + currentView.load([query], options); + } + else { + currentView.applyFilter(filterString, null, true); + } + break; + case "downloads": + if (currentView == ContentTree.view) { + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = filterString; + query.setTransitions([Ci.nsINavHistoryService.TRANSITION_DOWNLOAD], 1); + let options = currentOptions.clone(); + // Make sure we're getting uri results. + options.resultType = currentOptions.RESULTS_AS_URI; + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY; + options.includeHidden = true; + currentView.load([query], options); + } + else { + // The new downloads view doesn't use places for searching downloads. + currentView.searchTerm = filterString; + } + break; + default: + throw new Components.Exception("Invalid filterCollection on search", + Components.results.NS_ERROR_INVALID_ARG); + } + + PlacesSearchBox.showSearchUI(); + + // Update the details panel + PlacesOrganizer.updateDetailsPane(); + }, + + /** + * Finds across all history, downloads or all bookmarks. + */ + findAll: function() { + switch (this.filterCollection) { + case "history": + PlacesQueryBuilder.setScope("history"); + break; + case "downloads": + PlacesQueryBuilder.setScope("downloads"); + break; + default: + PlacesQueryBuilder.setScope("bookmarks"); + break; + } + this.focus(); + }, + + /** + * Updates the display with the title of the current collection. + * @param aTitle + * The title of the current collection. + */ + updateCollectionTitle: function(aTitle) { + let title = ""; + // This is needed when a user performs a folder-specific search + // using the scope bar, removes the search-string, and unfocuses + // the search box, at least until the removal of the scope bar. + if (aTitle) { + title = PlacesUIUtils.getFormattedString("searchCurrentDefault", + [aTitle]); + } + else { + switch (this.filterCollection) { + case "history": + title = PlacesUIUtils.getString("searchHistory"); + break; + case "downloads": + title = PlacesUIUtils.getString("searchDownloads"); + break; + default: + title = PlacesUIUtils.getString("searchBookmarks"); + } + } + this.searchFilter.placeholder = title; + }, + + /** + * Gets/sets the active collection from the dropdown menu. + */ + get filterCollection() { + return this.searchFilter.getAttribute("collection"); + }, + set filterCollection(collectionName) { + if (collectionName == this.filterCollection) + return collectionName; + + this.searchFilter.setAttribute("collection", collectionName); + + var newGrayText = null; + if (collectionName == "collection") { + newGrayText = PlacesOrganizer._places.selectedNode.title || + document.getElementById("scopeBarFolder"). + getAttribute("emptytitle"); + } + this.updateCollectionTitle(newGrayText); + return collectionName; + }, + + /** + * Focus the search box + */ + focus: function() { + this.searchFilter.focus(); + }, + + /** + * Set up the gray text in the search bar as the Places View loads. + */ + init: function() { + this.updateCollectionTitle(); + }, + + /** + * Gets or sets the text shown in the Places Search Box + */ + get value() { + return this.searchFilter.value; + }, + set value(value) { + return this.searchFilter.value = value; + }, + + showSearchUI: function() { + // Hide the advanced search controls when the user hasn't searched + var searchModifiers = document.getElementById("searchModifiers"); + searchModifiers.hidden = false; + }, + + hideSearchUI: function() { + var searchModifiers = document.getElementById("searchModifiers"); + searchModifiers.hidden = true; + } +}; + +/** + * Functions and data for advanced query builder + */ +var PlacesQueryBuilder = { + + queries: [], + queryOptions: null, + + /** + * Called when a scope button in the scope bar is clicked. + * @param aButton + * the scope button that was selected + */ + onScopeSelected: function(aButton) { + switch (aButton.id) { + case "scopeBarHistory": + this.setScope("history"); + break; + case "scopeBarFolder": + this.setScope("collection"); + break; + case "scopeBarDownloads": + this.setScope("downloads"); + break; + case "scopeBarAll": + this.setScope("bookmarks"); + break; + default: + throw new Components.Exception("Invalid search scope button ID", + Components.results.NS_ERROR_INVALID_ARG); + break; + } + }, + + /** + * Sets the search scope. This can be called when no search is active, and + * in that case, when the user does begin a search aScope will be used (see + * PSB_search()). If there is an active search, it's performed again to + * update the content tree. + * @param aScope + * The search scope: "bookmarks", "collection", "downloads" or + * "history". + */ + setScope: function(aScope) { + // Determine filterCollection, folders, and scopeButtonId based on aScope. + var filterCollection; + var folders = []; + var scopeButtonId; + switch (aScope) { + case "history": + filterCollection = "history"; + scopeButtonId = "scopeBarHistory"; + break; + case "collection": + // The folder scope button can only become hidden upon selecting a new + // folder in the left pane, and the disabled state will remain unchanged + // until a new folder is selected. See PO__setScopeForNode(). + if (!document.getElementById("scopeBarFolder").hidden) { + filterCollection = "collection"; + scopeButtonId = "scopeBarFolder"; + folders.push(PlacesUtils.getConcreteItemId( + PlacesOrganizer._places.selectedNode)); + break; + } + // Fall through. If collection scope doesn't make sense for the + // selected node, choose bookmarks scope. + case "bookmarks": + filterCollection = "bookmarks"; + scopeButtonId = "scopeBarAll"; + folders.push(PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.toolbarFolderId, + PlacesUtils.unfiledBookmarksFolderId); + break; + case "downloads": + filterCollection = "downloads"; + scopeButtonId = "scopeBarDownloads"; + break; + default: + throw new Components.Exception("Invalid search scope", + Components.results.NS_ERROR_INVALID_ARG); + break; + } + + // Check the appropriate scope button in the scope bar. + document.getElementById(scopeButtonId).checked = true; + + // Update the search box. Re-search if there's an active search. + PlacesSearchBox.filterCollection = filterCollection; + PlacesSearchBox.folders = folders; + var searchStr = PlacesSearchBox.searchFilter.value; + if (searchStr) + PlacesSearchBox.search(searchStr); + } +}; + +/** + * Population and commands for the View Menu. + */ +var ViewMenu = { + /** + * Removes content generated previously from a menupopup. + * @param popup + * The popup that contains the previously generated content. + * @param startID + * The id attribute of an element that is the start of the + * dynamically generated region - remove elements after this + * item only. + * Must be contained by popup. Can be null (in which case the + * contents of popup are removed). + * @param endID + * The id attribute of an element that is the end of the + * dynamically generated region - remove elements up to this + * item only. + * Must be contained by popup. Can be null (in which case all + * items until the end of the popup will be removed). Ignored + * if startID is null. + * @returns The element for the caller to insert new items before, + * null if the caller should just append to the popup. + */ + _clean: function(popup, startID, endID) { + if (endID) + NS_ASSERT(startID, "meaningless to have valid endID and null startID"); + if (startID) { + var startElement = document.getElementById(startID); + NS_ASSERT(startElement.parentNode == + popup, "startElement is not in popup"); + NS_ASSERT(startElement, + "startID does not correspond to an existing element"); + var endElement = null; + if (endID) { + endElement = document.getElementById(endID); + NS_ASSERT(endElement.parentNode == popup, + "endElement is not in popup"); + NS_ASSERT(endElement, + "endID does not correspond to an existing element"); + } + while (startElement.nextSibling != endElement) + popup.removeChild(startElement.nextSibling); + return endElement; + } + else { + while(popup.hasChildNodes()) + popup.removeChild(popup.firstChild); + } + return null; + }, + + /** + * Fills a menupopup with a list of columns + * @param event + * The popupshowing event that invoked this function. + * @param startID + * see _clean + * @param endID + * see _clean + * @param type + * the type of the menuitem, e.g. "radio" or "checkbox". + * Can be null (no-type). + * Checkboxes are checked if the column is visible. + * @param propertyPrefix + * If propertyPrefix is non-null: + * propertyPrefix + column ID + ".label" will be used to get the + * localized label string. + * propertyPrefix + column ID + ".accesskey" will be used to get the + * localized accesskey. + * If propertyPrefix is null, the column label is used as label and + * no accesskey is assigned. + */ + fillWithColumns: function(event, startID, endID, type, propertyPrefix) { + var popup = event.target; + var pivot = this._clean(popup, startID, endID); + + // If no column is "sort-active", the "Unsorted" item needs to be checked, + // so track whether or not we find a column that is sort-active. + var isSorted = false; + var content = document.getElementById("placeContent"); + var columns = content.columns; + for (var i = 0; i < columns.count; ++i) { + var column = columns.getColumnAt(i).element; + if (popup.parentNode && (popup.parentNode.id == "viewSort")) { + switch (column.id) { + case "placesContentParentFolder": + continue; + case "placesContentParentFolderPath": + continue; + } + } + var menuitem = document.createElement("menuitem"); + menuitem.id = "menucol_" + column.id; + menuitem.column = column; + var label = column.getAttribute("label"); + if (propertyPrefix) { + var menuitemPrefix = propertyPrefix; + // for string properties, use "name" as the id, instead of "title" + // see bug #386287 for details + var columnId = column.getAttribute("anonid"); + menuitemPrefix += columnId == "title" ? "name" : columnId; + label = PlacesUIUtils.getString(menuitemPrefix + ".label"); + var accesskey = PlacesUIUtils.getString(menuitemPrefix + ".accesskey"); + menuitem.setAttribute("accesskey", accesskey); + } + menuitem.setAttribute("label", label); + if (type == "radio") { + menuitem.setAttribute("type", "radio"); + menuitem.setAttribute("name", "columns"); + // This column is the sort key. Its item is checked. + if (column.getAttribute("sortDirection") != "") { + menuitem.setAttribute("checked", "true"); + isSorted = true; + } + } + else if (type == "checkbox") { + menuitem.setAttribute("type", "checkbox"); + // Cannot uncheck the primary column. + if (column.getAttribute("primary") == "true") + menuitem.setAttribute("disabled", "true"); + // Items for visible columns are checked. + if (!column.hidden) + menuitem.setAttribute("checked", "true"); + } + if (pivot) + popup.insertBefore(menuitem, pivot); + else + popup.appendChild(menuitem); + } + event.stopPropagation(); + }, + + /** + * Set up the content of the view menu. + */ + populateSortMenu: function(event) { + this.fillWithColumns(event, "viewUnsorted", "directionSeparator", "radio", "view.sortBy."); + + var sortColumn = this._getSortColumn(); + var viewSortAscending = document.getElementById("viewSortAscending"); + var viewSortDescending = document.getElementById("viewSortDescending"); + // We need to remove an existing checked attribute because the unsorted + // menu item is not rebuilt every time we open the menu like the others. + var viewUnsorted = document.getElementById("viewUnsorted"); + if (!sortColumn) { + viewSortAscending.removeAttribute("checked"); + viewSortDescending.removeAttribute("checked"); + viewUnsorted.setAttribute("checked", "true"); + } + else if (sortColumn.getAttribute("sortDirection") == "ascending") { + viewSortAscending.setAttribute("checked", "true"); + viewSortDescending.removeAttribute("checked"); + viewUnsorted.removeAttribute("checked"); + } + else if (sortColumn.getAttribute("sortDirection") == "descending") { + viewSortDescending.setAttribute("checked", "true"); + viewSortAscending.removeAttribute("checked"); + viewUnsorted.removeAttribute("checked"); + } + }, + + /** + * Shows/Hides a tree column. + * @param element + * The menuitem element for the column + */ + showHideColumn: function(element) { + var column = element.column; + + var splitter = column.nextSibling; + if (splitter && splitter.localName != "splitter") + splitter = null; + + if (element.getAttribute("checked") == "true") { + column.setAttribute("hidden", "false"); + if (splitter) + splitter.removeAttribute("hidden"); + } + else { + column.setAttribute("hidden", "true"); + if (splitter) + splitter.setAttribute("hidden", "true"); + } + }, + + /** + * Gets the last column that was sorted. + * @returns the currently sorted column, null if there is no sorted column. + */ + _getSortColumn: function() { + var content = document.getElementById("placeContent"); + var cols = content.columns; + for (var i = 0; i < cols.count; ++i) { + var column = cols.getColumnAt(i).element; + var sortDirection = column.getAttribute("sortDirection"); + if (sortDirection == "ascending" || sortDirection == "descending") + return column; + } + return null; + }, + + /** + * Sorts the view by the specified column. + * @param aColumn + * The colum that is the sort key. Can be null - the + * current sort column or the title column will be used. + * @param aDirection + * The direction to sort - "ascending" or "descending". + * Can be null - the last direction or descending will be used. + * + * If both aColumnID and aDirection are null, the view will be unsorted. + */ + setSortColumn: function(aColumn, aDirection) { + var result = document.getElementById("placeContent").result; + if (!aColumn && !aDirection) { + result.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + return; + } + + var columnId; + if (aColumn) { + columnId = aColumn.getAttribute("anonid"); + if (!aDirection) { + var sortColumn = this._getSortColumn(); + if (sortColumn) + aDirection = sortColumn.getAttribute("sortDirection"); + } + } + else { + var sortColumn = this._getSortColumn(); + columnId = sortColumn ? sortColumn.getAttribute("anonid") : "title"; + } + + // This maps the possible values of columnId (i.e., anonid's of treecols in + // placeContent) to the default sortingMode and sortingAnnotation values for + // each column. + // key: Sort key in the name of one of the + // nsINavHistoryQueryOptions.SORT_BY_* constants + // dir: Default sort direction to use if none has been specified + // anno: The annotation to sort by, if key is "ANNOTATION" + var colLookupTable = { + title: { key: "TITLE", dir: "ascending" }, + tags: { key: "TAGS", dir: "ascending" }, + url: { key: "URI", dir: "ascending" }, + date: { key: "DATE", dir: "descending" }, + visitCount: { key: "VISITCOUNT", dir: "descending" }, + keyword: { key: "KEYWORD", dir: "ascending" }, + dateAdded: { key: "DATEADDED", dir: "descending" }, + lastModified: { key: "LASTMODIFIED", dir: "descending" }, + description: { key: "ANNOTATION", + dir: "ascending", + anno: PlacesUIUtils.DESCRIPTION_ANNO } + }; + + // Make sure we have a valid column. + if (!colLookupTable.hasOwnProperty(columnId)) + throw new Components.Exception("Invalid column", + Components.results.NS_ERROR_INVALID_ARG); + + // Use a default sort direction if none has been specified. If aDirection + // is invalid, result.sortingMode will be undefined, which has the effect + // of unsorting the tree. + aDirection = (aDirection || colLookupTable[columnId].dir).toUpperCase(); + + var sortConst = "SORT_BY_" + colLookupTable[columnId].key + "_" + aDirection; + result.sortingAnnotation = colLookupTable[columnId].anno || ""; + result.sortingMode = Ci.nsINavHistoryQueryOptions[sortConst]; + } +} + +var ContentArea = { + _specialViews: new Map(), + + init: function() { + this._deck = document.getElementById("placesViewsDeck"); + this._toolbar = document.getElementById("placesToolbar"); + ContentTree.init(); + this._setupView(); + }, + + /** + * Gets the content view to be used for loading the given query. + * If a custom view was set by setContentViewForQueryString, that + * view would be returned, else the default tree view is returned + * + * @param aQueryString + * a query string + * @return the view to be used for loading aQueryString. + */ + getContentViewForQueryString: + function(aQueryString) { + try { + if (this._specialViews.has(aQueryString)) { + let { view, options } = this._specialViews.get(aQueryString); + if (typeof view == "function") { + view = view(); + this._specialViews.set(aQueryString, { view: view, options: options }); + } + return view; + } + } + catch(ex) { + Components.utils.reportError(ex); + } + return ContentTree.view; + }, + + /** + * Sets a custom view to be used rather than the default places tree + * whenever the given query is selected in the left pane. + * @param aQueryString + * a query string + * @param aView + * Either the custom view or a function that will return the view + * the first (and only) time it's called. + * @param [optional] aOptions + * Object defining special options for the view. + * @see ContentTree.viewOptions for supported options and default values. + */ + setContentViewForQueryString: + function(aQueryString, aView, aOptions) { + if (!aQueryString || + typeof aView != "object" && typeof aView != "function") + throw new Components.Exception("Invalid arguments", + Components.results.NS_ERROR_INVALID_ARG); + + this._specialViews.set(aQueryString, { view: aView, + options: aOptions || new Object() }); + }, + + get currentView() PlacesUIUtils.getViewForNode(this._deck.selectedPanel), + set currentView(aNewView) { + let oldView = this.currentView; + if (oldView != aNewView) { + this._deck.selectedPanel = aNewView.associatedElement; + + // If the content area inactivated view was focused, move focus + // to the new view. + if (document.activeElement == oldView.associatedElement) + aNewView.associatedElement.focus(); + } + return aNewView; + }, + + get currentPlace() this.currentView.place, + set currentPlace(aQueryString) { + let oldView = this.currentView; + let newView = this.getContentViewForQueryString(aQueryString); + newView.place = aQueryString; + if (oldView != newView) { + oldView.active = false; + this.currentView = newView; + this._setupView(); + newView.active = true; + } + return aQueryString; + }, + + /** + * Applies view options. + */ + _setupView: function() { + let options = this.currentViewOptions; + + // showDetailsPane. + let detailsDeck = document.getElementById("detailsDeck"); + detailsDeck.hidden = !options.showDetailsPane; + + // toolbarSet. + for (let elt of this._toolbar.childNodes) { + // On Windows and Linux the menu buttons are menus wrapped in a menubar. + if (elt.id == "placesMenu") { + for (let menuElt of elt.childNodes) { + menuElt.hidden = options.toolbarSet.indexOf(menuElt.id) == -1; + } + } + else { + elt.hidden = options.toolbarSet.indexOf(elt.id) == -1; + } + } + }, + + /** + * Options for the current view. + * + * @see ContentTree.viewOptions for supported options and default values. + */ + get currentViewOptions() { + // Use ContentTree options as default. + let viewOptions = ContentTree.viewOptions; + if (this._specialViews.has(this.currentPlace)) { + let { view, options } = this._specialViews.get(this.currentPlace); + for (let option in options) { + viewOptions[option] = options[option]; + } + } + return viewOptions; + }, + + focus: function() { + this._deck.selectedPanel.focus(); + } +}; + +var ContentTree = { + init: function() { + this._view = document.getElementById("placeContent"); + }, + + get view() this._view, + + get viewOptions() Object.seal({ + showDetailsPane: true, + toolbarSet: "back-button, forward-button, organizeButton, viewMenu, maintenanceButton, libraryToolbarSpacer, searchFilter" + }), + + openSelectedNode: function(aEvent) { + let view = this.view; + PlacesUIUtils.openNodeWithEvent(view.selectedNode, aEvent, view); + }, + + onClick: function(aEvent) { + let node = this.view.selectedNode; + if (node) { + let doubleClick = aEvent.button == 0 && aEvent.detail == 2; + let middleClick = aEvent.button == 1 && aEvent.detail == 1; + if (PlacesUtils.nodeIsURI(node) && (doubleClick || middleClick)) { + // Open associated uri in the browser. + this.openSelectedNode(aEvent); + } + else if (middleClick && PlacesUtils.nodeIsContainer(node)) { + // The command execution function will take care of seeing if the + // selection is a folder or a different container type, and will + // load its contents in tabs. + PlacesUIUtils.openContainerNodeInTabs(node, aEvent, this.view); + } + } + }, + + onKeyPress: function(aEvent) { + if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) + this.openSelectedNode(aEvent); + } +}; diff --git a/browser/components/places/content/places.xul b/browser/components/places/content/places.xul new file mode 100644 index 000000000..666937dde --- /dev/null +++ b/browser/components/places/content/places.xul @@ -0,0 +1,424 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://browser/content/places/places.css"?> +<?xml-stylesheet href="chrome://browser/content/places/organizer.css"?> + +<?xml-stylesheet href="chrome://global/skin/"?> +<?xml-stylesheet href="chrome://browser/skin/places/places.css"?> +<?xml-stylesheet href="chrome://browser/skin/places/organizer.css"?> + +<?xul-overlay href="chrome://browser/content/places/editBookmarkOverlay.xul"?> + +<?xul-overlay href="chrome://browser/content/baseMenuOverlay.xul"?> +<?xul-overlay href="chrome://global/content/editMenuOverlay.xul"?> +<?xul-overlay href="chrome://browser/content/places/placesOverlay.xul"?> + +<!DOCTYPE window [ +<!ENTITY % placesDTD SYSTEM "chrome://browser/locale/places/places.dtd"> +%placesDTD; +<!ENTITY % editMenuOverlayDTD SYSTEM "chrome://global/locale/editMenuOverlay.dtd"> +%editMenuOverlayDTD; +<!ENTITY % browserDTD SYSTEM "chrome://browser/locale/browser.dtd"> +%browserDTD; +]> + +<window id="places" + title="&places.library.title;" + windowtype="Places:Organizer" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="PlacesOrganizer.init();" + onunload="PlacesOrganizer.destroy();" + width="&places.library.width;" height="&places.library.height;" + screenX="10" screenY="10" + toggletoolbar="true" + persist="width height screenX screenY sizemode"> + + <script type="application/javascript" + src="chrome://browser/content/places/places.js"/> + <script type="application/javascript" + src="chrome://browser/content/utilityOverlay.js"/> + <script type="application/javascript" + src="chrome://browser/content/places/editBookmarkOverlay.js"/> + + <stringbundleset id="placesStringSet"> + <stringbundle id="brandStrings" src="chrome://branding/locale/brand.properties"/> + </stringbundleset> + + <commandset id="editMenuCommands"/> + <commandset id="placesCommands"/> + <keyset id="placesCommandKeys"/> + + <commandset id="organizerCommandSet"> + <command id="OrganizerCommand_find:all" + oncommand="PlacesSearchBox.findAll();"/> + <command id="OrganizerCommand_export" + oncommand="PlacesOrganizer.exportBookmarks();"/> + <command id="OrganizerCommand_import" + oncommand="PlacesOrganizer.importFromFile();"/> + <command id="OrganizerCommand_backup" + oncommand="PlacesOrganizer.backupBookmarks();"/> + <command id="OrganizerCommand_restoreFromFile" + oncommand="PlacesOrganizer.onRestoreBookmarksFromFile();"/> + <command id="OrganizerCommand_search:save" + oncommand="PlacesOrganizer.saveSearch();"/> + <command id="OrganizerCommand_search:moreCriteria" + oncommand="PlacesQueryBuilder.addRow();"/> + <command id="OrganizerCommand:Back" + oncommand="PlacesOrganizer.back();"/> + <command id="OrganizerCommand:Forward" + oncommand="PlacesOrganizer.forward();"/> + </commandset> + + <keyset id="placesOrganizerKeyset"> + <!-- Instantiation Keys --> + <key id="placesKey_close" key="&cmd.close.key;" modifiers="accel" + oncommand="close();"/> + + <!-- Command Keys --> + <key id="placesKey_find:all" + command="OrganizerCommand_find:all" + key="&cmd.find.key;" + modifiers="accel"/> + + <!-- Back/Forward Keys Support --> + <key id="placesKey_goBackKb" + keycode="VK_LEFT" + command="OrganizerCommand:Back" + modifiers="alt"/> + <key id="placesKey_goForwardKb" + keycode="VK_RIGHT" + command="OrganizerCommand:Forward" + modifiers="alt"/> +#ifdef XP_UNIX + <key id="placesKey_goBackKb2" + key="&goBackCmd.commandKey;" + command="OrganizerCommand:Back" + modifiers="accel"/> + <key id="placesKey_goForwardKb2" + key="&goForwardCmd.commandKey;" + command="OrganizerCommand:Forward" + modifiers="accel"/> +#endif + </keyset> + + <keyset id="editMenuKeys"> + </keyset> + + <popupset id="placesPopupset"> + <menupopup id="placesContext"/> + <menupopup id="placesColumnsContext" + onpopupshowing="ViewMenu.fillWithColumns(event, null, null, 'checkbox', null);" + oncommand="ViewMenu.showHideColumn(event.target); event.stopPropagation();"/> + </popupset> + + <toolbox id="placesToolbox"> + <toolbar class="chromeclass-toolbar" id="placesToolbar" align="center"> + <toolbarbutton id="back-button" + command="OrganizerCommand:Back" + tooltiptext="&backButton.tooltip;" + disabled="true"/> + + <toolbarbutton id="forward-button" + command="OrganizerCommand:Forward" + tooltiptext="&forwardButton.tooltip;" + disabled="true"/> + +#ifdef MOZ_WIDGET_GTK + <menubar id="placesMenu" _moz-menubarkeeplocal="true"> +#else + <menubar id="placesMenu"> +#endif + <menu accesskey="&organize.accesskey;" class="menu-iconic" + id="organizeButton" label="&organize.label;" + tooltiptext="&organize.tooltip;"> + <menupopup id="organizeButtonPopup"> + <menuitem id="newbookmark" + command="placesCmd_new:bookmark" + label="&cmd.new_bookmark.label;" + accesskey="&cmd.new_bookmark.accesskey;"/> + <menuitem id="newfolder" + command="placesCmd_new:folder" + label="&cmd.new_folder.label;" + accesskey="&cmd.new_folder.accesskey;"/> + <menuitem id="newseparator" + command="placesCmd_new:separator" + label="&cmd.new_separator.label;" + accesskey="&cmd.new_separator.accesskey;"/> + + <menuseparator id="orgUndoSeparator"/> + + <menuitem id="orgUndo" + command="cmd_undo" + label="&undoCmd.label;" + key="key_undo" + accesskey="&undoCmd.accesskey;"/> + <menuitem id="orgRedo" + command="cmd_redo" + label="&redoCmd.label;" + key="key_redo" + accesskey="&redoCmd.accesskey;"/> + + <menuseparator id="orgCutSeparator"/> + + <menuitem id="orgCut" + command="cmd_cut" + label="&cutCmd.label;" + key="key_cut" + accesskey="&cutCmd.accesskey;" + selection="separator|link|folder|mixed"/> + <menuitem id="orgCopy" + command="cmd_copy" + label="©Cmd.label;" + key="key_copy" + accesskey="©Cmd.accesskey;" + selection="separator|link|folder|mixed"/> + <menuitem id="orgPaste" + command="cmd_paste" + label="&pasteCmd.label;" + key="key_paste" + accesskey="&pasteCmd.accesskey;" + selection="mutable"/> + <menuitem id="orgDelete" + command="cmd_delete" + label="&deleteCmd.label;" + key="key_delete" + accesskey="&deleteCmd.accesskey;"/> + + <menuseparator id="selectAllSeparator"/> + + <menuitem id="orgSelectAll" + command="cmd_selectAll" + label="&selectAllCmd.label;" + key="key_selectAll" + accesskey="&selectAllCmd.accesskey;"/> + + <menuseparator id="orgMoveSeparator"/> + + <menuitem id="orgMoveBookmarks" + command="placesCmd_moveBookmarks" + label="&cmd.moveBookmarks.label;" + accesskey="&cmd.moveBookmarks.accesskey;"/> + <menuseparator id="orgCloseSeparator"/> + + <menuitem id="orgClose" + key="placesKey_close" + label="&file.close.label;" + accesskey="&file.close.accesskey;" + oncommand="close();"/> + </menupopup> + </menu> + <menu accesskey="&views.accesskey;" class="menu-iconic" + id="viewMenu" label="&views.label;" + tooltiptext="&views.tooltip;"> + <menupopup id="viewMenuPopup"> + + <menu id="viewColumns" + label="&view.columns.label;" accesskey="&view.columns.accesskey;"> + <menupopup onpopupshowing="ViewMenu.fillWithColumns(event, null, null, 'checkbox', null);" + oncommand="ViewMenu.showHideColumn(event.target); event.stopPropagation();"/> + </menu> + + <menu id="viewSort" label="&view.sort.label;" + accesskey="&view.sort.accesskey;"> + <menupopup onpopupshowing="ViewMenu.populateSortMenu(event);" + oncommand="ViewMenu.setSortColumn(event.target.column, null);"> + <menuitem id="viewUnsorted" type="radio" name="columns" + label="&view.unsorted.label;" accesskey="&view.unsorted.accesskey;" + oncommand="ViewMenu.setSortColumn(null, null);"/> + <menuseparator id="directionSeparator"/> + <menuitem id="viewSortAscending" type="radio" name="direction" + label="&view.sortAscending.label;" accesskey="&view.sortAscending.accesskey;" + oncommand="ViewMenu.setSortColumn(null, 'ascending'); event.stopPropagation();"/> + <menuitem id="viewSortDescending" type="radio" name="direction" + label="&view.sortDescending.label;" accesskey="&view.sortDescending.accesskey;" + oncommand="ViewMenu.setSortColumn(null, 'descending'); event.stopPropagation();"/> + </menupopup> + </menu> + </menupopup> + </menu> + <menu accesskey="&maintenance.accesskey;" class="menu-iconic" + id="maintenanceButton" label="&maintenance.label;" + tooltiptext="&maintenance.tooltip;"> + <menupopup id="maintenanceButtonPopup"> + <menuitem id="backupBookmarks" + command="OrganizerCommand_backup" + label="&cmd.backup.label;" + accesskey="&cmd.backup.accesskey;"/> + <menu id="fileRestoreMenu" label="&cmd.restore2.label;" + accesskey="&cmd.restore2.accesskey;"> + <menupopup id="fileRestorePopup" onpopupshowing="PlacesOrganizer.populateRestoreMenu();"> + <menuitem id="restoreFromFile" + command="OrganizerCommand_restoreFromFile" + label="&cmd.restoreFromFile.label;" + accesskey="&cmd.restoreFromFile.accesskey;"/> + </menupopup> + </menu> + <menuseparator/> + <menuitem id="fileImport" + command="OrganizerCommand_import" + label="&importBookmarksFromHTML.label;" + accesskey="&importBookmarksFromHTML.accesskey;"/> + <menuitem id="fileExport" + command="OrganizerCommand_export" + label="&exportBookmarksToHTML.label;" + accesskey="&exportBookmarksToHTML.accesskey;"/> + </menupopup> + </menu> + </menubar> + + <spacer id="libraryToolbarSpacer" flex="1"/> + + <textbox id="searchFilter" + clickSelectsAll="true" + type="search" + aria-controls="placeContent" + oncommand="PlacesSearchBox.search(this.value);" + collection="bookmarks"> + </textbox> + </toolbar> + </toolbox> + + <hbox flex="1" id="placesView"> + <tree id="placesList" + class="plain placesTree" + type="places" + hidecolumnpicker="true" context="placesContext" + onselect="PlacesOrganizer.onPlaceSelected(true);" + onclick="PlacesOrganizer.onPlacesListClick(event);" + onfocus="PlacesOrganizer.updateDetailsPane(event);" + seltype="single" + persist="width" + width="200" + minwidth="100" + maxwidth="400"> + <treecols> + <treecol anonid="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren flex="1"/> + </tree> + <splitter collapse="none" persist="state"></splitter> + <vbox id="contentView" flex="4"> + <toolbox id="searchModifiers" hidden="true"> + <toolbar id="organizerScopeBar" class="chromeclass-toolbar" align="center"> + <label id="scopeBarTitle" value="&search.in.label;"/> + <toolbarbutton id="scopeBarAll" class="small-margin" + type="radio" group="scopeBar" + oncommand="PlacesQueryBuilder.onScopeSelected(this);" + label="&search.scopeBookmarks.label;" + accesskey="&search.scopeBookmarks.accesskey;"/> + <toolbarbutton id="scopeBarHistory" class="small-margin" + type="radio" group="scopeBar" + oncommand="PlacesQueryBuilder.onScopeSelected(this);" + label="&search.scopeHistory.label;" + accesskey="&search.scopeHistory.accesskey;"/> + <toolbarbutton id="scopeBarDownloads" class="small-margin" + type="radio" group="scopeBar" + oncommand="PlacesQueryBuilder.onScopeSelected(this);" + label="&search.scopeDownloads.label;" + accesskey="&search.scopeDownloads.accesskey;"/> + <toolbarbutton id="scopeBarFolder" class="small-margin" + type="radio" group="scopeBar" + oncommand="PlacesQueryBuilder.onScopeSelected(this);" + accesskey="&search.scopeFolder.accesskey;" + emptytitle="&search.scopeFolder.label;" flex="1"/> + <!-- The folder scope button should flex but not take up more room + than its label needs. The only simple way to do that is to + set a really big flex on the spacer, e.g., 2^31 - 1. --> + <spacer flex="2147483647"/> + <button id="saveSearch" class="small-margin" + label="&saveSearch.label;" accesskey="&saveSearch.accesskey;" + command="OrganizerCommand_search:save"/> + </toolbar> + </toolbox> + <deck id="placesViewsDeck" + selectedIndex="0" + flex="1"> + <tree id="placeContent" + class="plain placesTree" + context="placesContext" + hidecolumnpicker="true" + flex="1" + type="places" + flatList="true" + selectfirstnode="true" + enableColumnDrag="true" + onfocus="PlacesOrganizer.updateDetailsPane(event)" + onselect="PlacesOrganizer.updateDetailsPane(event)" + onkeypress="ContentTree.onKeyPress(event);" + onopenflatcontainer="PlacesOrganizer.openFlatContainer(aContainer);"> + <treecols id="placeContentColumns" context="placesColumnsContext"> + <treecol label="&col.name.label;" id="placesContentTitle" anonid="title" flex="5" primary="true" ordinal="1" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.tags.label;" id="placesContentTags" anonid="tags" flex="2" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.url.label;" id="placesContentUrl" anonid="url" flex="5" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.lastvisit.label;" id="placesContentDate" anonid="date" flex="1" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.visitcount.label;" id="placesContentVisitCount" anonid="visitCount" flex="1" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.keyword.label;" id="placesContentKeyword" anonid="keyword" flex="1" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.description.label;" id="placesContentDescription" anonid="description" flex="1" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.dateadded.label;" id="placesContentDateAdded" anonid="dateAdded" flex="1" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.lastmodified.label;" id="placesContentLastModified" anonid="lastModified" flex="1" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.parentfolder.label;" id="placesContentParentFolder" anonid="parentFolder" flex="1" hidden="true" + persist="width hidden ordinal"/> + <splitter class="tree-splitter"/> + <treecol label="&col.parentfolderpath.label;" id="placesContentParentFolderPath" anonid="parentFolderPath" flex="1" hidden="true" + persist="width hidden ordinal"/> + </treecols> + <treechildren flex="1" onclick="ContentTree.onClick(event);"/> + </tree> + </deck> + <deck id="detailsDeck" style="height: 11em;"> + <vbox id="itemsCountBox" align="center"> + <spacer flex="3"/> + <label id="itemsCountText"/> + <spacer flex="1"/> + <description id="selectItemDescription"> + &detailsPane.selectAnItemText.description; + </description> + <spacer flex="3"/> + </vbox> + <vbox id="infoBox" minimal="true"> + <vbox id="editBookmarkPanelContent" flex="1"/> + <hbox id="infoBoxExpanderWrapper" align="center"> + + <button type="image" id="infoBoxExpander" + class="expander-down" + oncommand="PlacesOrganizer.toggleAdditionalInfoFields();" + observes="paneElementsBroadcaster"/> + + <label id="infoBoxExpanderLabel" + lesslabel="&detailsPane.less.label;" + lessaccesskey="&detailsPane.less.accesskey;" + morelabel="&detailsPane.more.label;" + moreaccesskey="&detailsPane.more.accesskey;" + value="&detailsPane.more.label;" + accesskey="&detailsPane.more.accesskey;" + control="infoBoxExpander"/> + + </hbox> + </vbox> + </deck> + </vbox> + </hbox> +</window> diff --git a/browser/components/places/content/placesOverlay.xul b/browser/components/places/content/placesOverlay.xul new file mode 100644 index 000000000..59115a57f --- /dev/null +++ b/browser/components/places/content/placesOverlay.xul @@ -0,0 +1,247 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE overlay [ +<!ENTITY % placesDTD SYSTEM "chrome://browser/locale/places/places.dtd"> +%placesDTD; +<!ENTITY % editMenuOverlayDTD SYSTEM "chrome://global/locale/editMenuOverlay.dtd"> +%editMenuOverlayDTD; +]> + +<overlay id="placesOverlay" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript" + src="chrome://global/content/globalOverlay.js"/> + <script type="application/javascript" + src="chrome://browser/content/utilityOverlay.js"/> + <script type="application/javascript"><![CDATA[ + // TODO: Bug 406371. + // A bunch of browser code depends on us defining these, sad but true :( + var Cc = Components.classes; + var Ci = Components.interfaces; + var Cr = Components.results; + + Components.utils.import("resource://gre/modules/PlacesUtils.jsm"); + Components.utils.import("resource:///modules/PlacesUIUtils.jsm"); + ]]></script> + <script type="application/javascript" + src="chrome://browser/content/places/controller.js"/> + <script type="application/javascript" + src="chrome://browser/content/places/treeView.js"/> + + <!-- Bookmarks and history tooltip --> + <tooltip id="bhTooltip" noautohide="true" + onpopupshowing="return window.top.BookmarksEventHandler.fillInBHTooltip(document, event)"> + <vbox id="bhTooltipTextBox" flex="1"> + <label id="bhtTitleText" class="tooltip-label" /> + <label id="bhtUrlText" crop="center" class="tooltip-label" /> + </vbox> + </tooltip> + + <commandset id="placesCommands" + commandupdater="true" + events="focus,sort,places" + oncommandupdate="goUpdatePlacesCommands();"> + <command id="placesCmd_open" + oncommand="goDoPlacesCommand('placesCmd_open');"/> + <command id="placesCmd_open:window" + oncommand="goDoPlacesCommand('placesCmd_open:window');"/> + <command id="placesCmd_open:privatewindow" + oncommand="goDoPlacesCommand('placesCmd_open:privatewindow');"/> + <command id="placesCmd_open:tab" + oncommand="goDoPlacesCommand('placesCmd_open:tab');"/> + + <command id="placesCmd_new:bookmark" + oncommand="goDoPlacesCommand('placesCmd_new:bookmark');"/> + <command id="placesCmd_new:livemark" + oncommand="goDoPlacesCommand('placesCmd_new:livemark');"/> + <command id="placesCmd_new:folder" + oncommand="goDoPlacesCommand('placesCmd_new:folder');"/> + <command id="placesCmd_new:separator" + oncommand="goDoPlacesCommand('placesCmd_new:separator');"/> + <command id="placesCmd_show:info" + oncommand="goDoPlacesCommand('placesCmd_show:info');"/> + <command id="placesCmd_rename" + oncommand="goDoPlacesCommand('placesCmd_show:info');" + observes="placesCmd_show:info"/> + <command id="placesCmd_reload" + oncommand="goDoPlacesCommand('placesCmd_reload');"/> + <command id="placesCmd_sortBy:name" + oncommand="goDoPlacesCommand('placesCmd_sortBy:name');"/> + <command id="placesCmd_moveBookmarks" + oncommand="goDoPlacesCommand('placesCmd_moveBookmarks');"/> + <command id="placesCmd_deleteDataHost" + oncommand="goDoPlacesCommand('placesCmd_deleteDataHost');"/> + <command id="placesCmd_createBookmark" + oncommand="goDoPlacesCommand('placesCmd_createBookmark');"/> + <command id="placesCmd_openParentFolder" + oncommand="goDoPlacesCommand('placesCmd_openParentFolder');"/> + + <!-- Special versions of cut/copy/paste/delete which check for an open context menu. --> + <command id="placesCmd_cut" + oncommand="goDoPlacesCommand('placesCmd_cut');"/> + <command id="placesCmd_copy" + oncommand="goDoPlacesCommand('placesCmd_copy');"/> + <command id="placesCmd_paste" + oncommand="goDoPlacesCommand('placesCmd_paste');"/> + <command id="placesCmd_delete" + oncommand="goDoPlacesCommand('placesCmd_delete');"/> + </commandset> + + <keyset id="placesCommandKeys"> + <key id="key_placesCmd_openParentFolder" + keycode="VK_F1" + command="placesCmd_openParentFolder" + modifiers="accel,shift"/> + </keyset> + + <menupopup id="placesContext" + onpopupshowing="this._view = PlacesUIUtils.getViewForNode(document.popupNode); + return this._view.buildContextMenu(this);" + onpopuphiding="this._view.destroyContextMenu();"> + <menuitem id="placesContext_open" + command="placesCmd_open" + label="&cmd.open.label;" + accesskey="&cmd.open.accesskey;" + default="true" + selectiontype="single" + selection="link"/> + <menuitem id="placesContext_open:newtab" + command="placesCmd_open:tab" + label="&cmd.open_tab.label;" + accesskey="&cmd.open_tab.accesskey;" + selectiontype="single" + selection="link"/> + <menuitem id="placesContext_openContainer:tabs" + oncommand="var view = PlacesUIUtils.getViewForNode(document.popupNode); + view.controller.openSelectionInTabs(event);" + onclick="checkForMiddleClick(this, event);" + label="&cmd.open_all_in_tabs.label;" + accesskey="&cmd.open_all_in_tabs.accesskey;" + selectiontype="single" + selection="folder|host|query"/> + <menuitem id="placesContext_openLinks:tabs" + oncommand="var view = PlacesUIUtils.getViewForNode(document.popupNode); + view.controller.openSelectionInTabs(event);" + onclick="checkForMiddleClick(this, event);" + label="&cmd.open_all_in_tabs.label;" + accesskey="&cmd.open_all_in_tabs.accesskey;" + selectiontype="multiple" + selection="link"/> + <menuitem id="placesContext_open:newwindow" + command="placesCmd_open:window" + label="&cmd.open_window.label;" + accesskey="&cmd.open_window.accesskey;" + selectiontype="single" + selection="link"/> + <menuitem id="placesContext_open:newprivatewindow" + command="placesCmd_open:privatewindow" + label="&cmd.open_private_window.label;" + accesskey="&cmd.open_private_window.accesskey;" + selectiontype="single" + selection="link" + hideifprivatebrowsing="true"/> + <menuseparator id="placesContext_openSeparator"/> + <menuitem id="placesContext_new:bookmark" + command="placesCmd_new:bookmark" + label="&cmd.new_bookmark.label;" + accesskey="&cmd.new_bookmark.accesskey;" + selectiontype="any" + hideifnoinsertionpoint="true"/> + <menuitem id="placesContext_new:folder" + command="placesCmd_new:folder" + label="&cmd.new_folder.label;" + accesskey="&cmd.context_new_folder.accesskey;" + selectiontype="any" + hideifnoinsertionpoint="true"/> + <menuitem id="placesContext_new:separator" + command="placesCmd_new:separator" + label="&cmd.new_separator.label;" + accesskey="&cmd.new_separator.accesskey;" + closemenu="single" + selectiontype="any" + hideifnoinsertionpoint="true"/> + <menuseparator id="placesContext_newSeparator"/> + <menuitem id="placesContext_createBookmark" + command="placesCmd_createBookmark" + label="&cmd.bookmarkLink.label;" + accesskey="&cmd.bookmarkLink.accesskey;" + selection="link" + forcehideselection="bookmark|tagChild"/> + <menuitem id="placesContext_cut" + command="placesCmd_cut" + label="&cutCmd.label;" + accesskey="&cutCmd.accesskey;" + closemenu="single" + selection="bookmark|folder|separator|query" + forcehideselection="tagChild|livemarkChild"/> + <menuitem id="placesContext_copy" + command="placesCmd_copy" + label="©Cmd.label;" + closemenu="single" + accesskey="©Cmd.accesskey;"/> + <menuitem id="placesContext_paste" + command="placesCmd_paste" + label="&pasteCmd.label;" + closemenu="single" + accesskey="&pasteCmd.accesskey;" + selectiontype="any" + hideifnoinsertionpoint="true"/> + <menuseparator id="placesContext_editSeparator"/> + <menuitem id="placesContext_delete" + command="placesCmd_delete" + label="&deleteCmd.label;" + accesskey="&deleteCmd.accesskey;" + closemenu="single" + selection="bookmark|tagChild|folder|query|dynamiccontainer|separator|host"/> + <menuitem id="placesContext_delete_history" + command="placesCmd_delete" + label="&cmd.delete.label;" + accesskey="&cmd.delete.accesskey;" + closemenu="single" + selection="link" + forcehideselection="bookmark|livemarkChild"/> + <menuitem id="placesContext_deleteHost" + command="placesCmd_deleteDataHost" + label="&cmd.deleteDomainData.label;" + accesskey="&cmd.deleteDomainData.accesskey;" + closemenu="single" + selection="link|host" + selectiontype="single" + hideifprivatebrowsing="true" + forcehideselection="bookmark|livemarkChild"/> + <menuseparator id="placesContext_deleteSeparator"/> + <menuitem id="placesContext_reload" + command="placesCmd_reload" + label="&cmd.reloadLivebookmark.label;" + accesskey="&cmd.reloadLivebookmark.accesskey;" + closemenu="single" + selection="livemark/feedURI"/> + <menuitem id="placesContext_sortBy:name" + command="placesCmd_sortBy:name" + label="&cmd.sortby_name.label;" + accesskey="&cmd.context_sortby_name.accesskey;" + closemenu="single" + selection="folder"/> + <menuseparator id="placesContext_sortSeparator"/> + <menuitem id="placesContext_openParentFolder" + command="placesCmd_openParentFolder" + label="&cmd.openParentFolder.label;" + key="key_placesCmd_openParentFolder" + accesskey="&cmd.openParentFolder.accesskey;" + selectiontype="single" + selection="bookmark" + forcehideselection="livemarkChild|livemark/feedURI|PlacesOrganizer/OrganizerQuery"/> + <menuseparator id="placesContext_parentFolderSeparator"/> + <menuitem id="placesContext_show:info" + command="placesCmd_show:info" + label="&cmd.properties.label;" + accesskey="&cmd.properties.accesskey;" + selection="bookmark|folder|query" + forcehideselection="livemarkChild"/> + </menupopup> + +</overlay> diff --git a/browser/components/places/content/sidebarUtils.js b/browser/components/places/content/sidebarUtils.js new file mode 100644 index 000000000..66ea10377 --- /dev/null +++ b/browser/components/places/content/sidebarUtils.js @@ -0,0 +1,104 @@ +// -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +var SidebarUtils = { + handleTreeClick: function(aTree, aEvent, aGutterSelect) { + // right-clicks are not handled here + if (aEvent.button == 2) + return; + + var tbo = aTree.treeBoxObject; + var cell = tbo.getCellAt(aEvent.clientX, aEvent.clientY); + + if (cell.row == -1 || cell.childElt == "twisty") + return; + + var mouseInGutter = false; + if (aGutterSelect) { + var rect = tbo.getCoordsForCellItem(cell.row, cell.col, "image"); + // getCoordsForCellItem returns the x coordinate in logical coordinates + // (i.e., starting from the left and right sides in LTR and RTL modes, + // respectively.) Therefore, we make sure to exclude the blank area + // before the tree item icon (that is, to the left or right of it in + // LTR and RTL modes, respectively) from the click target area. + var isRTL = window.getComputedStyle(aTree, null).direction == "rtl"; + if (isRTL) + mouseInGutter = aEvent.clientX > rect.x; + else + mouseInGutter = aEvent.clientX < rect.x; + } + + var modifKey = aEvent.ctrlKey || aEvent.shiftKey; + + var isContainer = tbo.view.isContainer(cell.row); + var openInTabs = isContainer && + (aEvent.button == 1 || + (aEvent.button == 0 && modifKey)) && + PlacesUtils.hasChildURIs(tbo.view.nodeForTreeIndex(cell.row)); + + if (aEvent.button == 0 && isContainer && !openInTabs) { + tbo.view.toggleOpenState(cell.row); + return; + } + else if (!mouseInGutter && openInTabs && + aEvent.originalTarget.localName == "treechildren") { + tbo.view.selection.select(cell.row); + PlacesUIUtils.openContainerNodeInTabs(aTree.selectedNode, aEvent, aTree); + } + else if (!mouseInGutter && !isContainer && + aEvent.originalTarget.localName == "treechildren") { + // Clear all other selection since we're loading a link now. We must + // do this *before* attempting to load the link since openURL uses + // selection as an indication of which link to load. + tbo.view.selection.select(cell.row); + PlacesUIUtils.openNodeWithEvent(aTree.selectedNode, aEvent, aTree); + } + }, + + handleTreeKeyPress: function(aEvent) { + // XXX Bug 627901: Post Fx4, this method should take a tree parameter. + let tree = aEvent.target; + let node = tree.selectedNode; + if (node) { + if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) + PlacesUIUtils.openNodeWithEvent(node, aEvent, tree); + } + }, + + /** + * The following function displays the URL of a node that is being + * hovered over. + */ + handleTreeMouseMove: function(aEvent) { + if (aEvent.target.localName != "treechildren") + return; + + var tree = aEvent.target.parentNode; + var tbo = tree.treeBoxObject; + var cell = tbo.getCellAt(aEvent.clientX, aEvent.clientY); + + // cell.row is -1 when the mouse is hovering an empty area within the tree. + // To avoid showing a URL from a previously hovered node for a currently + // hovered non-url node, we must clear the moused-over URL in these cases. + if (cell.row != -1) { + var node = tree.view.nodeForTreeIndex(cell.row); + if (PlacesUtils.nodeIsURI(node)) + this.setMouseoverURL(node.uri); + else + this.setMouseoverURL(""); + } + else + this.setMouseoverURL(""); + }, + + setMouseoverURL: function(aURL) { + // When the browser window is closed with an open sidebar, the sidebar + // unload event happens after the browser's one. In this case + // top.XULBrowserWindow has been nullified already. + if (top.XULBrowserWindow) { + top.XULBrowserWindow.setOverLink(aURL, null); + } + } +}; diff --git a/browser/components/places/content/tree.xml b/browser/components/places/content/tree.xml new file mode 100644 index 000000000..05b016941 --- /dev/null +++ b/browser/components/places/content/tree.xml @@ -0,0 +1,789 @@ +<?xml version="1.0"?> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<bindings id="placesTreeBindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <binding id="places-tree" extends="chrome://global/content/bindings/tree.xml#tree"> + <implementation> + <constructor><![CDATA[ + // Force an initial build. + if (this.place) + this.place = this.place; + ]]></constructor> + + <destructor><![CDATA[ + // Break the treeviewer->result->treeviewer cycle. + // Note: unsetting the result's viewer also unsets + // the viewer's reference to our treeBoxObject. + var result = this.result; + if (result) { + result.root.containerOpen = false; + } + + // Unregister the controllber before unlinking the view, otherwise it + // may still try to update commands on a view with a null result. + if (this._controller) { + this._controller.terminate(); + this.controllers.removeController(this._controller); + } + + this.view = null; + ]]></destructor> + + <property name="controller" + readonly="true" + onget="return this._controller"/> + + <!-- overriding --> + <property name="view"> + <getter><![CDATA[ + try { + return this.treeBoxObject.view.wrappedJSObject; + } + catch(e) { + return null; + } + ]]></getter> + <setter><![CDATA[ + return this.treeBoxObject.view = val; + ]]></setter> + </property> + + <property name="associatedElement" + readonly="true" + onget="return this"/> + + <method name="applyFilter"> + <parameter name="filterString"/> + <parameter name="folderRestrict"/> + <parameter name="includeHidden"/> + <body><![CDATA[ + // preserve grouping + var queryNode = PlacesUtils.asQuery(this.result.root); + var options = queryNode.queryOptions.clone(); + + // Make sure we're getting uri results. + // We do not yet support searching into grouped queries or into + // tag containers, so we must fall to the default case. + if (PlacesUtils.nodeIsHistoryContainer(queryNode) || + options.resultType == options.RESULTS_AS_TAG_QUERY || + options.resultType == options.RESULTS_AS_TAG_CONTENTS) + options.resultType = options.RESULTS_AS_URI; + + var query = PlacesUtils.history.getNewQuery(); + query.searchTerms = filterString; + + if (folderRestrict) { + query.setFolders(folderRestrict, folderRestrict.length); + options.queryType = options.QUERY_TYPE_BOOKMARKS; + } + + options.includeHidden = !!includeHidden; + + this.load([query], options); + ]]></body> + </method> + + <method name="load"> + <parameter name="queries"/> + <parameter name="options"/> + <body><![CDATA[ + let result = PlacesUtils.history + .executeQueries(queries, queries.length, + options); + let callback; + if (this.flatList) { + let onOpenFlatContainer = this.onOpenFlatContainer; + if (onOpenFlatContainer) + callback = new Function("aContainer", onOpenFlatContainer); + } + + if (!this._controller) { + this._controller = new PlacesController(this); + this.controllers.appendController(this._controller); + } + + let treeView = new PlacesTreeView(this.flatList, callback, this._controller); + + // Observer removal is done within the view itself. When the tree + // goes away, treeboxobject calls view.setTree(null), which then + // calls removeObserver. + result.addObserver(treeView, false); + this.view = treeView; + + if (this.getAttribute("selectfirstnode") == "true" && treeView.rowCount > 0) { + treeView.selection.select(0); + } + + this._cachedInsertionPoint = undefined; + ]]></body> + </method> + + <property name="flatList"> + <getter><![CDATA[ + return this.getAttribute("flatList") == "true"; + ]]></getter> + <setter><![CDATA[ + if (this.flatList != val) { + this.setAttribute("flatList", val); + // reload with the last place set + if (this.place) + this.place = this.place; + } + return val; + ]]></setter> + </property> + + <property name="onOpenFlatContainer"> + <getter><![CDATA[ + return this.getAttribute("onopenflatcontainer"); + ]]></getter> + <setter><![CDATA[ + if (this.onOpenFlatContainer != val) { + this.setAttribute("onopenflatcontainer", val); + // reload with the last place set + if (this.place) + this.place = this.place; + } + return val; + ]]></setter> + </property> + + <!-- + Causes a particular node represented by the specified placeURI to be + selected in the tree. All containers above the node in the hierarchy + will be opened, so that the node is visible. + --> + <method name="selectPlaceURI"> + <parameter name="placeURI"/> + <body><![CDATA[ + // Do nothing if a node matching the given uri is already selected + if (this.hasSelection && this.selectedNode.uri == placeURI) + return; + + function findNode(container, placeURI, nodesURIChecked) { + var containerURI = container.uri; + if (containerURI == placeURI) + return container; + if (nodesURIChecked.indexOf(containerURI) != -1) + return null; + + // never check the contents of the same query + nodesURIChecked.push(containerURI); + + var wasOpen = container.containerOpen; + if (!wasOpen) + container.containerOpen = true; + for (var i = 0; i < container.childCount; ++i) { + var child = container.getChild(i); + var childURI = child.uri; + if (childURI == placeURI) + return child; + else if (PlacesUtils.nodeIsContainer(child)) { + var nested = findNode(PlacesUtils.asContainer(child), placeURI, nodesURIChecked); + if (nested) + return nested; + } + } + + if (!wasOpen) + container.containerOpen = false; + + return null; + } + + var container = this.result.root; + NS_ASSERT(container, "No result, cannot select place URI!"); + if (!container) + return; + + var child = findNode(container, placeURI, []); + if (child) + this.selectNode(child); + else { + // If the specified child could not be located, clear the selection + var selection = this.view.selection; + selection.clearSelection(); + } + ]]></body> + </method> + + <!-- + Causes a particular node to be selected in the tree, resulting in all + containers above the node in the hierarchy to be opened, so that the + node is visible. + --> + <method name="selectNode"> + <parameter name="node"/> + <body><![CDATA[ + var view = this.view; + + var parent = node.parent; + if (parent && !parent.containerOpen) { + // Build a list of all of the nodes that are the parent of this one + // in the result. + var parents = []; + var root = this.result.root; + while (parent && parent != root) { + parents.push(parent); + parent = parent.parent; + } + + // Walk the list backwards (opening from the root of the hierarchy) + // opening each folder as we go. + for (var i = parents.length - 1; i >= 0; --i) { + var index = view.treeIndexForNode(parents[i]); + if (index != Ci.nsINavHistoryResultTreeViewer.INDEX_INVISIBLE && + view.isContainer(index) && !view.isContainerOpen(index)) + view.toggleOpenState(index); + } + // Select the specified node... + } + + var index = view.treeIndexForNode(node); + if (index == Ci.nsINavHistoryResultTreeViewer.INDEX_INVISIBLE) + return; + + view.selection.select(index); + // ... and ensure it's visible, not scrolled off somewhere. + this.treeBoxObject.ensureRowIsVisible(index); + ]]></body> + </method> + + <!-- nsIPlacesView --> + <property name="result"> + <getter><![CDATA[ + try { + return this.view.QueryInterface(Ci.nsINavHistoryResultObserver).result; + } + catch (e) { + return null; + } + ]]></getter> + </property> + + <!-- nsIPlacesView --> + <property name="place"> + <getter><![CDATA[ + return this.getAttribute("place"); + ]]></getter> + <setter><![CDATA[ + this.setAttribute("place", val); + + var queriesRef = { }; + var queryCountRef = { }; + var optionsRef = { }; + PlacesUtils.history.queryStringToQueries(val, queriesRef, queryCountRef, optionsRef); + if (queryCountRef.value == 0) + queriesRef.value = [PlacesUtils.history.getNewQuery()]; + if (!optionsRef.value) + optionsRef.value = PlacesUtils.history.getNewQueryOptions(); + + this.load(queriesRef.value, optionsRef.value); + + return val; + ]]></setter> + </property> + + <!-- nsIPlacesView --> + <property name="hasSelection"> + <getter><![CDATA[ + return this.view && this.view.selection.count >= 1; + ]]></getter> + </property> + + <!-- nsIPlacesView --> + <property name="selectedNodes"> + <getter><![CDATA[ + let nodes = []; + if (!this.hasSelection) + return nodes; + + let selection = this.view.selection; + let rc = selection.getRangeCount(); + let resultview = this.view; + for (let i = 0; i < rc; ++i) { + let min = { }, max = { }; + selection.getRangeAt(i, min, max); + + for (let j = min.value; j <= max.value; ++j) + nodes.push(resultview.nodeForTreeIndex(j)); + } + return nodes; + ]]></getter> + </property> + + <method name="toggleCutNode"> + <parameter name="aNode"/> + <parameter name="aValue"/> + <body><![CDATA[ + this.view.toggleCutNode(aNode, aValue); + ]]></body> + </method> + + <!-- nsIPlacesView --> + <property name="removableSelectionRanges"> + <getter><![CDATA[ + // This property exists in addition to selectedNodes because it + // encodes selection ranges (which only occur in list views) into + // the return value. For each removed range, the index at which items + // will be re-inserted upon the remove transaction being performed is + // the first index of the range, so that the view updates correctly. + // + // For example, if we remove rows 2,3,4 and 7,8 from a list, when we + // undo that operation, if we insert what was at row 3 at row 3 again, + // it will show up _after_ the item that was at row 5. So we need to + // insert all items at row 2, and the tree view will update correctly. + // + // Also, this function collapses the selection to remove redundant + // data, e.g. when deleting this selection: + // + // http://www.foo.com/ + // (-) Some Folder + // http://www.bar.com/ + // + // ... returning http://www.bar.com/ as part of the selection is + // redundant because it is implied by removing "Some Folder". We + // filter out all such redundancies since some partial amount of + // the folder's children may be selected. + // + let nodes = []; + if (!this.hasSelection) + return nodes; + + var selection = this.view.selection; + var rc = selection.getRangeCount(); + var resultview = this.view; + // This list is kept independently of the range selected (i.e. OUTSIDE + // the for loop) since the row index of a container is unique for the + // entire view, and we could have some really wacky selection and we + // don't want to blow up. + var containers = { }; + for (var i = 0; i < rc; ++i) { + var range = []; + var min = { }, max = { }; + selection.getRangeAt(i, min, max); + + for (var j = min.value; j <= max.value; ++j) { + if (this.view.isContainer(j)) + containers[j] = true; + if (!(this.view.getParentIndex(j) in containers)) + range.push(resultview.nodeForTreeIndex(j)); + } + nodes.push(range); + } + return nodes; + ]]></getter> + </property> + + <!-- nsIPlacesView --> + <property name="draggableSelection" + onget="return this.selectedNodes"/> + + <!-- nsIPlacesView --> + <property name="selectedNode"> + <getter><![CDATA[ + var view = this.view; + if (!view || view.selection.count != 1) + return null; + + var selection = view.selection; + var min = { }, max = { }; + selection.getRangeAt(0, min, max); + + return this.view.nodeForTreeIndex(min.value); + ]]></getter> + </property> + + <!-- nsIPlacesView --> + <property name="insertionPoint"> + <getter><![CDATA[ + // invalidated on selection and focus changes + if (this._cachedInsertionPoint !== undefined) + return this._cachedInsertionPoint; + + // there is no insertion point for history queries + // so bail out now and save a lot of work when updating commands + var resultNode = this.result.root; + if (PlacesUtils.nodeIsQuery(resultNode) && + PlacesUtils.asQuery(resultNode).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) + return this._cachedInsertionPoint = null; + + var orientation = Ci.nsITreeView.DROP_BEFORE; + // If there is no selection, insert at the end of the container. + if (!this.hasSelection) { + var index = this.view.rowCount - 1; + this._cachedInsertionPoint = + this._getInsertionPoint(index, orientation); + return this._cachedInsertionPoint; + } + + // This is a two-part process. The first part is determining the drop + // orientation. + // * The default orientation is to drop _before_ the selected item. + // * If the selected item is a container, the default orientation + // is to drop _into_ that container. + // + // Warning: It may be tempting to use tree indexes in this code, but + // you must not, since the tree is nested and as your tree + // index may change when folders before you are opened and + // closed. You must convert your tree index to a node, and + // then use getChildIndex to find your absolute index in + // the parent container instead. + // + var resultView = this.view; + var selection = resultView.selection; + var rc = selection.getRangeCount(); + var min = { }, max = { }; + selection.getRangeAt(rc - 1, min, max); + + // If the sole selection is a container, and we are not in + // a flatlist, insert into it. + // Note that this only applies to _single_ selections, + // if the last element within a multi-selection is a + // container, insert _adjacent_ to the selection. + // + // If the sole selection is the bookmarks toolbar folder, we insert + // into it even if it is not opened + var itemId = + PlacesUtils.getConcreteItemId(resultView.nodeForTreeIndex(max.value)); + if (selection.count == 1 && resultView.isContainer(max.value) && + !this.flatList) + orientation = Ci.nsITreeView.DROP_ON; + + this._cachedInsertionPoint = + this._getInsertionPoint(max.value, orientation); + return this._cachedInsertionPoint; + ]]></getter> + </property> + + <method name="_getInsertionPoint"> + <parameter name="index"/> + <parameter name="orientation"/> + <body><![CDATA[ + var result = this.result; + var resultview = this.view; + var container = result.root; + var dropNearItemId = -1; + NS_ASSERT(container, "null container"); + // When there's no selection, assume the container is the container + // the view is populated from (i.e. the result's itemId). + if (index != -1) { + var lastSelected = resultview.nodeForTreeIndex(index); + if (resultview.isContainer(index) && orientation == Ci.nsITreeView.DROP_ON) { + // If the last selected item is an open container, append _into_ + // it, rather than insert adjacent to it. + container = lastSelected; + index = -1; + } + else if (lastSelected.containerOpen && + orientation == Ci.nsITreeView.DROP_AFTER && + lastSelected.hasChildren) { + // If the last selected item is an open container and the user is + // trying to drag into it as a first item, really insert into it. + container = lastSelected; + orientation = Ci.nsITreeView.DROP_ON; + index = 0; + } + else { + // Use the last-selected node's container. + container = lastSelected.parent; + + // See comment in the treeView.js's copy of this method + if (!container || !container.containerOpen) + return null; + + // Avoid the potentially expensive call to getChildIndex + // if we know this container doesn't allow insertion + if (PlacesControllerDragHelper.disallowInsertion(container)) + return null; + + var queryOptions = PlacesUtils.asQuery(result.root).queryOptions; + if (queryOptions.sortingMode != + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) { + // If we are within a sorted view, insert at the end + index = -1; + } + else if (queryOptions.excludeItems || + queryOptions.excludeQueries || + queryOptions.excludeReadOnlyFolders) { + // Some item may be invisible, insert near last selected one. + // We don't replace index here to avoid requests to the db, + // instead it will be calculated later by the controller. + index = -1; + dropNearItemId = lastSelected.itemId; + } + else { + var lsi = container.getChildIndex(lastSelected); + index = orientation == Ci.nsITreeView.DROP_BEFORE ? lsi : lsi + 1; + } + } + } + + if (PlacesControllerDragHelper.disallowInsertion(container)) + return null; + + return new InsertionPoint(PlacesUtils.getConcreteItemId(container), + index, orientation, + PlacesUtils.nodeIsTagQuery(container), + dropNearItemId); + ]]></body> + </method> + + <!-- nsIPlacesView --> + <method name="selectAll"> + <body><![CDATA[ + this.view.selection.selectAll(); + ]]></body> + </method> + + <!-- This method will select the first node in the tree that matches + each given item id. It will open any parent nodes that it needs + to in order to show the selected items. + --> + <method name="selectItems"> + <parameter name="aIDs"/> + <parameter name="aOpenContainers"/> + <body><![CDATA[ + // By default, we do search and select within containers which were + // closed (note that containers in which nodes were not found are + // closed). + if (aOpenContainers === undefined) + aOpenContainers = true; + + var ids = aIDs; // don't manipulate the caller's array + + // Array of nodes found by findNodes which are to be selected + var nodes = []; + + // Array of nodes found by findNodes which should be opened + var nodesToOpen = []; + + // A set of URIs of container-nodes that were previously searched, + // and thus shouldn't be searched again. This is empty at the initial + // start of the recursion and gets filled in as the recursion + // progresses. + var nodesURIChecked = []; + + /** + * Recursively search through a node's children for items + * with the given IDs. When a matching item is found, remove its ID + * from the IDs array, and add the found node to the nodes dictionary. + * + * NOTE: This method will leave open any node that had matching items + * in its subtree. + */ + function findNodes(node) { + var foundOne = false; + // See if node matches an ID we wanted; add to results. + // For simple folder queries, check both itemId and the concrete + // item id. + var index = ids.indexOf(node.itemId); + if (index == -1 && + node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) + index = ids.indexOf(PlacesUtils.asQuery(node).folderItemId); + + if (index != -1) { + nodes.push(node); + foundOne = true; + ids.splice(index, 1); + } + + if (ids.length == 0 || !PlacesUtils.nodeIsContainer(node) || + nodesURIChecked.indexOf(node.uri) != -1) + return foundOne; + + PlacesUtils.asContainer(node); + if (!aOpenContainers && !node.containerOpen) + return foundOne; + + nodesURIChecked.push(node.uri); + + // Remember the beginning state so that we can re-close + // this node if we don't find any additional results here. + var previousOpenness = node.containerOpen; + node.containerOpen = true; + for (var child = 0; child < node.childCount && ids.length > 0; + child++) { + var childNode = node.getChild(child); + var found = findNodes(childNode); + if (!foundOne) + foundOne = found; + } + + // If we didn't find any additional matches in this node's + // subtree, revert the node to its previous openness. + if (foundOne) + nodesToOpen.unshift(node); + node.containerOpen = previousOpenness; + return foundOne; + } + + // Disable notifications while looking for nodes. + let result = this.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) + result.suppressNotifications = true + try { + findNodes(this.result.root); + } + finally { + if (!didSuppressNotifications) + result.suppressNotifications = false; + } + + // For all the nodes we've found, highlight the corresponding + // index in the tree. + var resultview = this.view; + var selection = this.view.selection; + selection.selectEventsSuppressed = true; + selection.clearSelection(); + // Open nodes containing found items + for (var i = 0; i < nodesToOpen.length; i++) { + nodesToOpen[i].containerOpen = true; + } + for (var i = 0; i < nodes.length; i++) { + var index = resultview.treeIndexForNode(nodes[i]); + if (index == Ci.nsINavHistoryResultTreeViewer.INDEX_INVISIBLE) + continue; + selection.rangedSelect(index, index, true); + } + selection.selectEventsSuppressed = false; + ]]></body> + </method> + + <field name="_contextMenuShown">false</field> + + <method name="buildContextMenu"> + <parameter name="aPopup"/> + <body><![CDATA[ + this._contextMenuShown = true; + return this.controller.buildContextMenu(aPopup); + ]]></body> + </method> + + <method name="destroyContextMenu"> + <parameter name="aPopup"/> + this._contextMenuShown = false; + <body/> + </method> + + <property name="ownerWindow" + readonly="true" + onget="return window;"/> + + <field name="_active">true</field> + <property name="active" + onget="return this._active" + onset="return this._active = val"/> + + </implementation> + <handlers> + <handler event="focus"><![CDATA[ + this._cachedInsertionPoint = undefined; + + // See select handler. We need the sidebar's places commandset to be + // updated as well + document.commandDispatcher.updateCommands("focus"); + ]]></handler> + <handler event="select"><![CDATA[ + this._cachedInsertionPoint = undefined; + + // This additional complexity is here for the sidebars + var win = window; + while (true) { + win.document.commandDispatcher.updateCommands("focus"); + if (win == window.top) + break; + + win = win.parent; + } + ]]></handler> + + <handler event="dragstart"><![CDATA[ + if (event.target.localName != "treechildren") + return; + + let nodes = this.selectedNodes; + for (let i = 0; i < nodes.length; i++) { + let node = nodes[i]; + + // Disallow dragging the root node of a tree. + if (!node.parent) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + // If this node is child of a readonly container (e.g. a livemark) + // or cannot be moved, we must force a copy. + if (!PlacesControllerDragHelper.canMoveNode(node)) { + event.dataTransfer.effectAllowed = "copyLink"; + break; + } + } + + this._controller.setDataTransfer(event); + event.stopPropagation(); + ]]></handler> + + <handler event="dragover"><![CDATA[ + if (event.target.localName != "treechildren") + return; + + let cell = this.treeBoxObject.getCellAt(event.clientX, event.clientY); + let node = cell.row != -1 ? + this.view.nodeForTreeIndex(cell.row) : + this.result.root; + // cache the dropTarget for the view + PlacesControllerDragHelper.currentDropTarget = node; + + // We have to calculate the orientation since view.canDrop will use + // it and we want to be consistent with the dropfeedback. + let tbo = this.treeBoxObject; + let rowHeight = tbo.rowHeight; + let eventY = event.clientY - tbo.treeBody.boxObject.y - + rowHeight * (cell.row - tbo.getFirstVisibleRow()); + + let orientation = Ci.nsITreeView.DROP_BEFORE; + + if (cell.row == -1) { + // If the row is not valid we try to insert inside the resultNode. + orientation = Ci.nsITreeView.DROP_ON; + } + else if (PlacesUtils.nodeIsContainer(node) && + eventY > rowHeight * 0.75) { + // If we are below the 75% of a container the treeview we try + // to drop after the node. + orientation = Ci.nsITreeView.DROP_AFTER; + } + else if (PlacesUtils.nodeIsContainer(node) && + eventY > rowHeight * 0.25) { + // If we are below the 25% of a container the treeview we try + // to drop inside the node. + orientation = Ci.nsITreeView.DROP_ON; + } + + if (!this.view.canDrop(cell.row, orientation, event.dataTransfer)) + return; + + event.preventDefault(); + event.stopPropagation(); + ]]></handler> + + <handler event="dragend"><![CDATA[ + PlacesControllerDragHelper.currentDropTarget = null; + ]]></handler> + + </handlers> + </binding> + +</bindings> diff --git a/browser/components/places/content/treeView.js b/browser/components/places/content/treeView.js new file mode 100644 index 000000000..db31ceebe --- /dev/null +++ b/browser/components/places/content/treeView.js @@ -0,0 +1,1770 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +Components.utils.import('resource://gre/modules/XPCOMUtils.jsm'); + +const PTV_interfaces = [Ci.nsITreeView, + Ci.nsINavHistoryResultObserver, + Ci.nsINavHistoryResultTreeViewer, + Ci.nsISupportsWeakReference]; + +function PlacesTreeView(aFlatList, aOnOpenFlatContainer, aController) { + this._tree = null; + this._result = null; + this._selection = null; + this._rootNode = null; + this._rows = []; + this._flatList = aFlatList; + this._openContainerCallback = aOnOpenFlatContainer; + this._controller = aController; +} + +PlacesTreeView.prototype = { + get wrappedJSObject() this, + + __dateService: null, + get _dateService() { + if (!this.__dateService) { + this.__dateService = Cc["@mozilla.org/intl/scriptabledateformat;1"]. + getService(Ci.nsIScriptableDateFormat); + } + return this.__dateService; + }, + + QueryInterface: XPCOMUtils.generateQI(PTV_interfaces), + + // Bug 761494: + // ---------- + // Some addons use methods from nsINavHistoryResultObserver and + // nsINavHistoryResultTreeViewer, without QIing to these interfaces first. + // That's not a problem when the view is retrieved through the + // <tree>.view getter (which returns the wrappedJSObject of this object), + // it raises an issue when the view retrieved through the treeBoxObject.view + // getter. Thus, to avoid breaking addons, the interfaces are prefetched. + classInfo: XPCOMUtils.generateCI({ interfaces: PTV_interfaces }), + + /** + * This is called once both the result and the tree are set. + */ + _finishInit: function() { + let selection = this.selection; + if (selection) + selection.selectEventsSuppressed = true; + + if (!this._rootNode.containerOpen) { + // This triggers containerStateChanged which then builds the visible + // section. + this._rootNode.containerOpen = true; + } + else + this.invalidateContainer(this._rootNode); + + // "Activate" the sorting column and update commands. + this.sortingChanged(this._result.sortingMode); + + if (selection) + selection.selectEventsSuppressed = false; + }, + + /** + * Plain Container: container result nodes which may never include sub + * hierarchies. + * + * When the rows array is constructed, we don't set the children of plain + * containers. Instead, we keep placeholders for these children. We then + * build these children lazily as the tree asks us for information about each + * row. Luckily, the tree doesn't ask about rows outside the visible area. + * + * @see _getNodeForRow and _getRowForNode for the actual magic. + * + * @note It's guaranteed that all containers are listed in the rows + * elements array. It's also guaranteed that separators (if they're not + * filtered, see below) are listed in the visible elements array, because + * bookmark folders are never built lazily, as described above. + * + * @param aContainer + * A container result node. + * + * @return true if aContainer is a plain container, false otherwise. + */ + _isPlainContainer: function(aContainer) { + // Livemarks are always plain containers. + if (this._controller.hasCachedLivemarkInfo(aContainer)) + return true; + + // We don't know enough about non-query containers. + if (!(aContainer instanceof Ci.nsINavHistoryQueryResultNode)) + return false; + + switch (aContainer.queryOptions.resultType) { + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY: + return false; + } + + // If it's a folder, it's not a plain container. + let nodeType = aContainer.type; + return nodeType != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER && + nodeType != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT; + }, + + /** + * Gets the row number for a given node. Assumes that the given node is + * visible (i.e. it's not an obsolete node). + * + * @param aNode + * A result node. Do not pass an obsolete node, or any + * node which isn't supposed to be in the tree (e.g. separators in + * sorted trees). + * @param [optional] aForceBuild + * @see _isPlainContainer. + * If true, the row will be computed even if the node still isn't set + * in our rows array. + * @param [optional] aParentRow + * The row of aNode's parent. Ignored for the root node. + * @param [optional] aNodeIndex + * The index of aNode in its parent. Only used if aParentRow is + * set too. + * + * @throws if aNode is invisible. + * @note If aParentRow and aNodeIndex are passed and parent is a plain + * container, this method will just return a calculated row value, without + * making assumptions on existence of the node at that position. + * @return aNode's row if it's in the rows list or if aForceBuild is set, -1 + * otherwise. + */ + _getRowForNode: + function(aNode, aForceBuild, aParentRow, aNodeIndex) { + if (aNode == this._rootNode) + throw new Error("The root node is never visible"); + + // A node is removed form the view either if it has no parent or if its + // root-ancestor is not the root node (in which case that's the node + // for which nodeRemoved was called). + // Tycho: let ancestors = [x for (x of PlacesUtils.nodeAncestors(aNode))]; + let ancestors = []; + for (let x of PlacesUtils.nodeAncestors(aNode)) { + ancestors.push(x); + } + + if (ancestors.length == 0 || + ancestors[ancestors.length - 1] != this._rootNode) { + throw new Error("Removed node passed to _getRowForNode"); + } + + // Ensure that the entire chain is open, otherwise that node is invisible. + for (let ancestor of ancestors) { + if (!ancestor.containerOpen) + throw new Error("Invisible node passed to _getRowForNode"); + } + + // Non-plain containers are initially built with their contents. + let parent = aNode.parent; + let parentIsPlain = this._isPlainContainer(parent); + if (!parentIsPlain) { + if (parent == this._rootNode) + return this._rows.indexOf(aNode); + + return this._rows.indexOf(aNode, aParentRow); + } + + let row = -1; + let useNodeIndex = typeof(aNodeIndex) == "number"; + if (parent == this._rootNode) { + if (aNode instanceof Ci.nsINavHistoryResultNode) { + row = useNodeIndex ? aNodeIndex : this._rootNode.getChildIndex(aNode); + } + } else if (useNodeIndex && typeof(aParentRow) == "number") { + // If we have both the row of the parent node, and the node's index, we + // can avoid searching the rows array if the parent is a plain container. + row = aParentRow + aNodeIndex + 1; + } else { + // Look for the node in the nodes array. Start the search at the parent + // row. If the parent row isn't passed, we'll pass undefined to indexOf, + // which is fine. + row = this._rows.indexOf(aNode, aParentRow); + if (row == -1 && aForceBuild) { + let parentRow = typeof(aParentRow) == "number" ? aParentRow + : this._getRowForNode(parent); + row = parentRow + parent.getChildIndex(aNode) + 1; + } + } + + if (row != -1) + this._rows[row] = aNode; + + return row; + }, + + /** + * Given a row, finds and returns the parent details of the associated node. + * + * @param aChildRow + * Row number. + * @return [parentNode, parentRow] + */ + _getParentByChildRow: function(aChildRow) { + let node = this._getNodeForRow(aChildRow); + let parent = (node === null) ? this._rootNode : node.parent; + + // The root node is never visible + if (parent == this._rootNode) + return [this._rootNode, -1]; + + let parentRow = this._rows.lastIndexOf(parent, aChildRow - 1); + return [parent, parentRow]; + }, + + /** + * Gets the node at a given row. + */ + _getNodeForRow: function(aRow) { + if (aRow < 0) { + return null; + } + + let node = this._rows[aRow]; + if (node !== undefined) + return node; + + // Find the nearest node. + let rowNode, row; + for (let i = aRow - 1; i >= 0 && rowNode === undefined; i--) { + rowNode = this._rows[i]; + row = i; + } + + // If there's no container prior to the given row, it's a child of + // the root node (remember: all containers are listed in the rows array). + if (!rowNode) + return this._rows[aRow] = this._rootNode.getChild(aRow); + + // Unset elements may exist only in plain containers. Thus, if the nearest + // node is a container, it's the row's parent, otherwise, it's a sibling. + if (rowNode instanceof Ci.nsINavHistoryContainerResultNode) + return this._rows[aRow] = rowNode.getChild(aRow - row - 1); + + let [parent, parentRow] = this._getParentByChildRow(row); + return this._rows[aRow] = parent.getChild(aRow - parentRow - 1); + }, + + /** + * This takes a container and recursively appends our rows array per its + * contents. Assumes that the rows arrays has no rows for the given + * container. + * + * @param [in] aContainer + * A container result node. + * @param [in] aFirstChildRow + * The first row at which nodes may be inserted to the row array. + * In other words, that's aContainer's row + 1. + * @param [out] aToOpen + * An array of containers to open once the build is done. + * + * @return the number of rows which were inserted. + */ + _buildVisibleSection: + function(aContainer, aFirstChildRow, aToOpen) + { + // There's nothing to do if the container is closed. + if (!aContainer.containerOpen) + return 0; + + // Inserting the new elements into the rows array in one shot (by + // Array.concat) is faster than resizing the array (by splice) on each loop + // iteration. + let cc = aContainer.childCount; + let newElements = new Array(cc); + this._rows = this._rows.splice(0, aFirstChildRow) + .concat(newElements, this._rows); + + if (this._isPlainContainer(aContainer)) + return cc; + + const openLiteral = PlacesUIUtils.RDF.GetResource("http://home.netscape.com/NC-rdf#open"); + const trueLiteral = PlacesUIUtils.RDF.GetLiteral("true"); + let sortingMode = this._result.sortingMode; + + let rowsInserted = 0; + for (let i = 0; i < cc; i++) { + let curChild = aContainer.getChild(i); + let curChildType = curChild.type; + + let row = aFirstChildRow + rowsInserted; + + // Don't display separators when sorted. + if (curChildType == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + if (sortingMode != Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) { + // Remove the element for the filtered separator. + // Notice that the rows array was initially resized to include all + // children. + this._rows.splice(row, 1); + continue; + } + } + + this._rows[row] = curChild; + rowsInserted++; + + // Recursively do containers. + if (!this._flatList && + curChild instanceof Ci.nsINavHistoryContainerResultNode && + !this._controller.hasCachedLivemarkInfo(curChild)) { + let resource = this._getResourceForNode(curChild); + let isopen = resource != null && + PlacesUIUtils.localStore.HasAssertion(resource, + openLiteral, + trueLiteral, true); + if (isopen != curChild.containerOpen) + aToOpen.push(curChild); + else if (curChild.containerOpen && curChild.childCount > 0) + rowsInserted += this._buildVisibleSection(curChild, row + 1, aToOpen); + } + } + + return rowsInserted; + }, + + /** + * This counts how many rows a node takes in the tree. For containers it + * will count the node itself plus any child node following it. + */ + _countVisibleRowsForNodeAtRow: + function(aNodeRow) { + let node = this._rows[aNodeRow]; + + // If it's not listed yet, we know that it's a leaf node (instanceof also + // null-checks). + if (!(node instanceof Ci.nsINavHistoryContainerResultNode)) + return 1; + + let outerLevel = node.indentLevel; + for (let i = aNodeRow + 1; i < this._rows.length; i++) { + let rowNode = this._rows[i]; + if (rowNode && rowNode.indentLevel <= outerLevel) + return i - aNodeRow; + } + + // This node plus its children take up the bottom of the list. + return this._rows.length - aNodeRow; + }, + + _getSelectedNodesInRange: + function(aFirstRow, aLastRow) { + let selection = this.selection; + let rc = selection.getRangeCount(); + if (rc == 0) + return []; + + // The visible-area borders are needed for checking whether a + // selected row is also visible. + let firstVisibleRow = this._tree.getFirstVisibleRow(); + let lastVisibleRow = this._tree.getLastVisibleRow(); + + let nodesInfo = []; + for (let rangeIndex = 0; rangeIndex < rc; rangeIndex++) { + let min = { }, max = { }; + selection.getRangeAt(rangeIndex, min, max); + + // If this range does not overlap the replaced chunk, we don't need to + // persist the selection. + if (max.value < aFirstRow || min.value > aLastRow) + continue; + + let firstRow = Math.max(min.value, aFirstRow); + let lastRow = Math.min(max.value, aLastRow); + for (let i = firstRow; i <= lastRow; i++) { + nodesInfo.push({ + node: this._rows[i], + oldRow: i, + wasVisible: i >= firstVisibleRow && i <= lastVisibleRow + }); + } + } + + return nodesInfo; + }, + + /** + * Tries to find an equivalent node for a node which was removed. We first + * look for the original node, in case it was just relocated. Then, if we + * that node was not found, we look for a node that has the same itemId, uri + * and time values. + * + * @param aUpdatedContainer + * An ancestor of the node which was removed. It does not have to be + * its direct parent. + * @param aOldNode + * The node which was removed. + * + * @return the row number of an equivalent node for aOldOne, if one was + * found, -1 otherwise. + */ + _getNewRowForRemovedNode: + function(aUpdatedContainer, aOldNode) { + if (aOldNode == undefined) { + return -1; + } + let parent = aOldNode.parent; + if (parent) { + // If the node's parent is still set, the node is not obsolete + // and we should just find out its new position. + // However, if any of the node's ancestor is closed, the node is + // invisible. + let ancestors = PlacesUtils.nodeAncestors(aOldNode); + for (let ancestor of ancestors) { + if (!ancestor.containerOpen) + return -1; + } + + return this._getRowForNode(aOldNode, true); + } + + // There's a broken edge case here. + // If a visit appears in two queries, and the second one was + // the old node, we'll select the first one after refresh. There's + // nothing we could do about that, because aOldNode.parent is + // gone by the time invalidateContainer is called. + let newNode = aUpdatedContainer.findNodeByDetails(aOldNode.uri, + aOldNode.time, + aOldNode.itemId, + true); + if (!newNode) + return -1; + + return this._getRowForNode(newNode, true); + }, + + /** + * Restores a given selection state as near as possible to the original + * selection state. + * + * @param aNodesInfo + * The persisted selection state as returned by + * _getSelectedNodesInRange. + * @param aUpdatedContainer + * The container which was updated. + */ + _restoreSelection: + function(aNodesInfo, aUpdatedContainer) { + if (aNodesInfo.length == 0) + return; + + let selection = this.selection; + + // Attempt to ensure that previously-visible selection will be visible + // if it's re-selected. However, we can only ensure that for one row. + let scrollToRow = -1; + for (let i = 0; i < aNodesInfo.length; i++) { + let nodeInfo = aNodesInfo[i]; + let row = this._getNewRowForRemovedNode(aUpdatedContainer, + nodeInfo.node); + // Select the found node, if any. + if (row != -1) { + selection.rangedSelect(row, row, true); + if (nodeInfo.wasVisible && scrollToRow == -1) + scrollToRow = row; + } + } + + // If only one node was previously selected and there's no selection now, + // select the node at its old row, if any. + if (aNodesInfo.length == 1 && selection.count == 0) { + let row = Math.min(aNodesInfo[0].oldRow, this._rows.length - 1); + if (row != -1) { + selection.rangedSelect(row, row, true); + if (aNodesInfo[0].wasVisible && scrollToRow == -1) + scrollToRow = aNodesInfo[0].oldRow; + } + } + + if (scrollToRow != -1) + this._tree.ensureRowIsVisible(scrollToRow); + }, + + _convertPRTimeToString: function(aTime) { + const MS_PER_MINUTE = 60000; + const MS_PER_DAY = 86400000; + let timeMs = aTime / 1000; // PRTime is in microseconds + + // Date is calculated starting from midnight, so the modulo with a day are + // milliseconds from today's midnight. + // getTimezoneOffset corrects that based on local time, notice midnight + // can have a different offset during DST-change days. + let dateObj = new Date(); + let now = dateObj.getTime() - dateObj.getTimezoneOffset() * MS_PER_MINUTE; + let midnight = now - (now % MS_PER_DAY); + midnight += new Date(midnight).getTimezoneOffset() * MS_PER_MINUTE; + + let dateFormat = timeMs >= midnight ? + Ci.nsIScriptableDateFormat.dateFormatNone : + Ci.nsIScriptableDateFormat.dateFormatShort; + + let timeObj = new Date(timeMs); + return (this._dateService.FormatDateTime("", dateFormat, + Ci.nsIScriptableDateFormat.timeFormatNoSeconds, + timeObj.getFullYear(), timeObj.getMonth() + 1, + timeObj.getDate(), timeObj.getHours(), + timeObj.getMinutes(), timeObj.getSeconds())); + }, + + COLUMN_TYPE_UNKNOWN: 0, + COLUMN_TYPE_TITLE: 1, + COLUMN_TYPE_URI: 2, + COLUMN_TYPE_DATE: 3, + COLUMN_TYPE_VISITCOUNT: 4, + COLUMN_TYPE_KEYWORD: 5, + COLUMN_TYPE_DESCRIPTION: 6, + COLUMN_TYPE_DATEADDED: 7, + COLUMN_TYPE_LASTMODIFIED: 8, + COLUMN_TYPE_TAGS: 9, + COLUMN_TYPE_PARENTFOLDER: 10, + COLUMN_TYPE_PARENTFOLDERPATH: 11, + + _getColumnType: function(aColumn) { + let columnType = aColumn.element.getAttribute("anonid") || aColumn.id; + + switch (columnType) { + case "title": + return this.COLUMN_TYPE_TITLE; + case "url": + return this.COLUMN_TYPE_URI; + case "date": + return this.COLUMN_TYPE_DATE; + case "visitCount": + return this.COLUMN_TYPE_VISITCOUNT; + case "keyword": + return this.COLUMN_TYPE_KEYWORD; + case "description": + return this.COLUMN_TYPE_DESCRIPTION; + case "dateAdded": + return this.COLUMN_TYPE_DATEADDED; + case "lastModified": + return this.COLUMN_TYPE_LASTMODIFIED; + case "tags": + return this.COLUMN_TYPE_TAGS; + case "parentFolder": + return this.COLUMN_TYPE_PARENTFOLDER; + case "parentFolderPath": + return this.COLUMN_TYPE_PARENTFOLDERPATH; + } + return this.COLUMN_TYPE_UNKNOWN; + }, + + _sortTypeToColumnType: function(aSortType) { + switch (aSortType) { + case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING: + return [this.COLUMN_TYPE_TITLE, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING: + return [this.COLUMN_TYPE_TITLE, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING: + return [this.COLUMN_TYPE_DATE, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING: + return [this.COLUMN_TYPE_DATE, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_URI_ASCENDING: + return [this.COLUMN_TYPE_URI, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_URI_DESCENDING: + return [this.COLUMN_TYPE_URI, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_ASCENDING: + return [this.COLUMN_TYPE_VISITCOUNT, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING: + return [this.COLUMN_TYPE_VISITCOUNT, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_KEYWORD_ASCENDING: + return [this.COLUMN_TYPE_KEYWORD, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_KEYWORD_DESCENDING: + return [this.COLUMN_TYPE_KEYWORD, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_ASCENDING: + if (this._result.sortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) + return [this.COLUMN_TYPE_DESCRIPTION, false]; + break; + case Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_DESCENDING: + if (this._result.sortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) + return [this.COLUMN_TYPE_DESCRIPTION, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING: + return [this.COLUMN_TYPE_DATEADDED, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING: + return [this.COLUMN_TYPE_DATEADDED, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_ASCENDING: + return [this.COLUMN_TYPE_LASTMODIFIED, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_DESCENDING: + return [this.COLUMN_TYPE_LASTMODIFIED, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_ASCENDING: + return [this.COLUMN_TYPE_TAGS, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_DESCENDING: + return [this.COLUMN_TYPE_TAGS, true]; + } + return [this.COLUMN_TYPE_UNKNOWN, false]; + }, + + // nsINavHistoryResultObserver + nodeInserted: function(aParentNode, aNode, aNewIndex) { + NS_ASSERT(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) + return; + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) + return; + + let parentRow; + if (aParentNode != this._rootNode) { + parentRow = this._getRowForNode(aParentNode); + + // Update parent when inserting the first item, since twisty has changed. + if (aParentNode.childCount == 1) + this._tree.invalidateRow(parentRow); + } + + // Compute the new row number of the node. + let row = -1; + let cc = aParentNode.childCount; + if (aNewIndex == 0 || this._isPlainContainer(aParentNode) || cc == 0) { + // We don't need to worry about sub hierarchies of the parent node + // if it's a plain container, or if the new node is its first child. + if (aParentNode == this._rootNode) + row = aNewIndex; + else + row = parentRow + aNewIndex + 1; + } + else { + // Here, we try to find the next visible element in the child list so we + // can set the new visible index to be right before that. Note that we + // have to search down instead of up, because some siblings could have + // children themselves that would be in the way. + let separatorsAreHidden = PlacesUtils.nodeIsSeparator(aNode) && + this.isSorted(); + for (let i = aNewIndex + 1; i < cc; i++) { + let node = aParentNode.getChild(i); + if (!separatorsAreHidden || PlacesUtils.nodeIsSeparator(node)) { + // The children have not been shifted so the next item will have what + // should be our index. + row = this._getRowForNode(node, false, parentRow, i); + break; + } + } + if (row < 0) { + // At the end of the child list without finding a visible sibling. This + // is a little harder because we don't know how many rows the last item + // in our list takes up (it could be a container with many children). + let prevChild = aParentNode.getChild(aNewIndex - 1); + let prevIndex = this._getRowForNode(prevChild, false, parentRow, + aNewIndex - 1); + row = prevIndex + this._countVisibleRowsForNodeAtRow(prevIndex); + } + } + + this._rows.splice(row, 0, aNode); + this._tree.rowCountChanged(row, 1); + + if (PlacesUtils.nodeIsContainer(aNode) && + PlacesUtils.asContainer(aNode).containerOpen) { + this.invalidateContainer(aNode); + } + }, + + /** + * THIS FUNCTION DOES NOT HANDLE cases where a collapsed node is being + * removed but the node it is collapsed with is not being removed (this then + * just swap out the removee with its collapsing partner). The only time + * when we really remove things is when deleting URIs, which will apply to + * all collapsees. This function is called sometimes when resorting items. + * However, we won't do this when sorted by date because dates will never + * change for visits, and date sorting is the only time things are collapsed. + */ + nodeRemoved: function(aParentNode, aNode, aOldIndex) { + NS_ASSERT(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) + return; + + // XXX bug 517701: We don't know what to do when the root node is removed. + if (aNode == this._rootNode) + throw Cr.NS_ERROR_NOT_IMPLEMENTED; + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) + return; + + let parentRow = aParentNode == this._rootNode ? + undefined : this._getRowForNode(aParentNode, true); + let oldRow = this._getRowForNode(aNode, true, parentRow, aOldIndex); + if (oldRow < 0) + throw Cr.NS_ERROR_UNEXPECTED; + + // If the node was exclusively selected, the node next to it will be + // selected. + let selectNext = false; + let selection = this.selection; + if (selection.getRangeCount() == 1) { + let min = { }, max = { }; + selection.getRangeAt(0, min, max); + if (min.value == max.value && + this.nodeForTreeIndex(min.value) == aNode) + selectNext = true; + } + + // Remove the node and its children, if any. + let count = this._countVisibleRowsForNodeAtRow(oldRow); + this._rows.splice(oldRow, count); + this._tree.rowCountChanged(oldRow, -count); + + // Redraw the parent if its twisty state has changed. + if (aParentNode != this._rootNode && !aParentNode.hasChildren) { + let parentRow = oldRow - 1; + this._tree.invalidateRow(parentRow); + } + + // Restore selection if the node was exclusively selected. + if (!selectNext) + return; + + // Restore selection. + let rowToSelect = Math.min(oldRow, this._rows.length - 1); + if (rowToSelect != -1) + this.selection.rangedSelect(rowToSelect, rowToSelect, true); + }, + + nodeMoved: + function(aNode, aOldParent, aOldIndex, aNewParent, aNewIndex) { + NS_ASSERT(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) + return; + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) + return; + + // Note that at this point the node has already been moved by the backend, + // so we must give hints to _getRowForNode to get the old row position. + let oldParentRow = aOldParent == this._rootNode ? + undefined : this._getRowForNode(aOldParent, true); + let oldRow = this._getRowForNode(aNode, true, oldParentRow, aOldIndex); + if (oldRow < 0) + throw Cr.NS_ERROR_UNEXPECTED; + + // If this node is a container it could take up more than one row. + let count = this._countVisibleRowsForNodeAtRow(oldRow); + + // Persist selection state. + let nodesToReselect = + this._getSelectedNodesInRange(oldRow, oldRow + count); + if (nodesToReselect.length > 0) + this.selection.selectEventsSuppressed = true; + + // Redraw the parent if its twisty state has changed. + if (aOldParent != this._rootNode && !aOldParent.hasChildren) { + let parentRow = oldRow - 1; + this._tree.invalidateRow(parentRow); + } + + // Remove node and its children, if any, from the old position. + this._rows.splice(oldRow, count); + this._tree.rowCountChanged(oldRow, -count); + + // Insert the node into the new position. + this.nodeInserted(aNewParent, aNode, aNewIndex); + + // Restore selection. + if (nodesToReselect.length > 0) { + this._restoreSelection(nodesToReselect, aNewParent); + this.selection.selectEventsSuppressed = false; + } + }, + + _invalidateCellValue: function(aNode, + aColumnType) { + NS_ASSERT(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) + return; + + // Nothing to do for the root node. + if (aNode == this._rootNode) + return; + + let row = this._getRowForNode(aNode); + if (row == -1) + return; + + let column = this._findColumnByType(aColumnType); + if (column && !column.element.hidden) + this._tree.invalidateCell(row, column); + + // Last modified time is altered for almost all node changes. + if (aColumnType != this.COLUMN_TYPE_LASTMODIFIED) { + let lastModifiedColumn = + this._findColumnByType(this.COLUMN_TYPE_LASTMODIFIED); + if (lastModifiedColumn && !lastModifiedColumn.hidden) + this._tree.invalidateCell(row, lastModifiedColumn); + } + }, + + _populateLivemarkContainer: function(aNode) { + PlacesUtils.livemarks.getLivemark({ id: aNode.itemId }) + .then(aLivemark => { + let placesNode = aNode; + // Need to check containerOpen since getLivemark is async. + if (!placesNode.containerOpen) + return; + + let children = aLivemark.getNodesForContainer(placesNode); + for (let i = 0; i < children.length; i++) { + let child = children[i]; + this.nodeInserted(placesNode, child, i); + } + }, Components.utils.reportError); + }, + + nodeTitleChanged: function(aNode, aNewTitle) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + }, + + nodeURIChanged: function(aNode, aNewURI) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_URI); + }, + + nodeIconChanged: function(aNode) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + }, + + nodeHistoryDetailsChanged: + function(aNode, aUpdatedVisitDate, + aUpdatedVisitCount) { + if (aNode.parent && this._controller.hasCachedLivemarkInfo(aNode.parent)) { + // Find the node in the parent. + let parentRow = this._flatList ? 0 : this._getRowForNode(aNode.parent); + for (let i = parentRow; i < this._rows.length; i++) { + let child = this.nodeForTreeIndex(i); + if (child.uri == aNode.uri) { + this._cellProperties.delete(child); + this._invalidateCellValue(child, this.COLUMN_TYPE_TITLE); + break; + } + } + return; + } + + this._invalidateCellValue(aNode, this.COLUMN_TYPE_DATE); + this._invalidateCellValue(aNode, this.COLUMN_TYPE_VISITCOUNT); + }, + + nodeTagsChanged: function(aNode) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TAGS); + }, + + nodeKeywordChanged: function(aNode, aNewKeyword) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_KEYWORD); + }, + + nodeAnnotationChanged: function(aNode, aAnno) { + if (aAnno == PlacesUIUtils.DESCRIPTION_ANNO) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_DESCRIPTION); + } + else if (aAnno == PlacesUtils.LMANNO_FEEDURI) { + PlacesUtils.livemarks.getLivemark({ id: aNode.itemId }) + .then(aLivemark => { + this._controller.cacheLivemarkInfo(aNode, aLivemark); + let properties = this._cellProperties.get(aNode); + this._cellProperties.set(aNode, properties += " livemark"); + // The livemark attribute is set as a cell property on the title cell. + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + }, Components.utils.reportError); + } + }, + + nodeDateAddedChanged: function(aNode, aNewValue) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_DATEADDED); + }, + + nodeLastModifiedChanged: + function(aNode, aNewValue) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_LASTMODIFIED); + }, + + containerStateChanged: + function(aNode, aOldState, aNewState) { + this.invalidateContainer(aNode); + + if (PlacesUtils.nodeIsFolder(aNode) || + (this._flatList && aNode == this._rootNode)) { + let queryOptions = PlacesUtils.asQuery(this._rootNode).queryOptions; + if (queryOptions.excludeItems) { + return; + } + if (aNode.itemId != -1) { // run when there's a valid node id + PlacesUtils.livemarks.getLivemark({ id: aNode.itemId }) + .then(aLivemark => { + let shouldInvalidate = + !this._controller.hasCachedLivemarkInfo(aNode); + this._controller.cacheLivemarkInfo(aNode, aLivemark); + if (aNewState == Components.interfaces.nsINavHistoryContainerResultNode.STATE_OPENED) { + aLivemark.registerForUpdates(aNode, this); + // Prioritize the current livemark. + aLivemark.reload(); + PlacesUtils.livemarks.reloadLivemarks(); + if (shouldInvalidate) + this.invalidateContainer(aNode); + } + else { + aLivemark.unregisterForUpdates(aNode); + } + }, () => undefined); + } + } + }, + + invalidateContainer: function(aContainer) { + NS_ASSERT(this._result, "Need to have a result to update"); + if (!this._tree) + return; + + let startReplacement, replaceCount; + if (aContainer == this._rootNode) { + startReplacement = 0; + replaceCount = this._rows.length; + + // If the root node is now closed, the tree is empty. + if (!this._rootNode.containerOpen) { + this._rows = []; + if (replaceCount) + this._tree.rowCountChanged(startReplacement, -replaceCount); + + return; + } + } + else { + // Update the twisty state. + let row = this._getRowForNode(aContainer); + this._tree.invalidateRow(row); + + // We don't replace the container node itself, so we should decrease the + // replaceCount by 1. + startReplacement = row + 1; + replaceCount = this._countVisibleRowsForNodeAtRow(row) - 1; + } + + // Persist selection state. + let nodesToReselect = + this._getSelectedNodesInRange(startReplacement, + startReplacement + replaceCount); + + // Now update the number of elements. + this.selection.selectEventsSuppressed = true; + + // First remove the old elements + this._rows.splice(startReplacement, replaceCount); + + // If the container is now closed, we're done. + if (!aContainer.containerOpen) { + let oldSelectionCount = this.selection.count; + if (replaceCount) + this._tree.rowCountChanged(startReplacement, -replaceCount); + + // Select the row next to the closed container if any of its + // children were selected, and nothing else is selected. + if (nodesToReselect.length > 0 && + nodesToReselect.length == oldSelectionCount) { + this.selection.rangedSelect(startReplacement, startReplacement, true); + this._tree.ensureRowIsVisible(startReplacement); + } + + this.selection.selectEventsSuppressed = false; + return; + } + + // Otherwise, start a batch first. + this._tree.beginUpdateBatch(); + if (replaceCount) + this._tree.rowCountChanged(startReplacement, -replaceCount); + + let toOpenElements = []; + let elementsAddedCount = this._buildVisibleSection(aContainer, + startReplacement, + toOpenElements); + if (elementsAddedCount) + this._tree.rowCountChanged(startReplacement, elementsAddedCount); + + if (!this._flatList) { + // Now, open any containers that were persisted. + for (let i = 0; i < toOpenElements.length; i++) { + let item = toOpenElements[i]; + let parent = item.parent; + + // Avoid recursively opening containers. + while (parent) { + if (parent.uri == item.uri) + break; + parent = parent.parent; + } + + // If we don't have a parent, we made it all the way to the root + // and didn't find a match, so we can open our item. + if (!parent && !item.containerOpen) + item.containerOpen = true; + } + } + + if (this._controller.hasCachedLivemarkInfo(aContainer)) { + let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions; + if (!queryOptions.excludeItems) { + this._populateLivemarkContainer(aContainer); + } + } + + this._tree.endUpdateBatch(); + + // Restore selection. + this._restoreSelection(nodesToReselect, aContainer); + this.selection.selectEventsSuppressed = false; + }, + + _columns: [], + _findColumnByType: function(aColumnType) { + if (this._columns[aColumnType]) + return this._columns[aColumnType]; + + let columns = this._tree.columns; + let colCount = columns.count; + for (let i = 0; i < colCount; i++) { + let column = columns.getColumnAt(i); + let columnType = this._getColumnType(column); + this._columns[columnType] = column; + if (columnType == aColumnType) + return column; + } + + // That's completely valid. Most of our trees actually include just the + // title column. + return null; + }, + + sortingChanged: function(aSortingMode) { + if (!this._tree || !this._result) + return; + + // Depending on the sort mode, certain commands may be disabled. + window.updateCommands("sort"); + + let columns = this._tree.columns; + + // Clear old sorting indicator. + let sortedColumn = columns.getSortedColumn(); + if (sortedColumn) + sortedColumn.element.removeAttribute("sortDirection"); + + // Set new sorting indicator by looking through all columns for ours. + if (aSortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) + return; + + let [desiredColumn, desiredIsDescending] = + this._sortTypeToColumnType(aSortingMode); + let colCount = columns.count; + let column = this._findColumnByType(desiredColumn); + if (column) { + let sortDir = desiredIsDescending ? "descending" : "ascending"; + column.element.setAttribute("sortDirection", sortDir); + } + }, + + _inBatchMode: false, + batching: function(aToggleMode) { + if (this._inBatchMode != aToggleMode) { + this._inBatchMode = this.selection.selectEventsSuppressed = aToggleMode; + if (this._inBatchMode) { + this._tree.beginUpdateBatch(); + } + else { + this._tree.endUpdateBatch(); + } + } + }, + + get result() this._result, + set result(val) { + if (this._result) { + this._result.removeObserver(this); + this._rootNode.containerOpen = false; + } + + if (val) { + this._result = val; + this._rootNode = this._result.root; + this._cellProperties = new Map(); + this._cuttingNodes = new Set(); + } + else if (this._result) { + delete this._result; + delete this._rootNode; + delete this._cellProperties; + delete this._cuttingNodes; + } + + // If the tree is not set yet, setTree will call finishInit. + if (this._tree && val) + this._finishInit(); + + return val; + }, + + nodeForTreeIndex: function(aIndex) { + if (aIndex > this._rows.length) + throw Cr.NS_ERROR_INVALID_ARG; + + return this._getNodeForRow(aIndex); + }, + + treeIndexForNode: function(aNode) { + // The API allows passing invisible nodes. + try { + return this._getRowForNode(aNode, true); + } + catch(ex) { } + + return Ci.nsINavHistoryResultTreeViewer.INDEX_INVISIBLE; + }, + + _getResourceForNode: function(aNode) + { + let uri = aNode.uri; + NS_ASSERT(uri, "if there is no uri, we can't persist the open state"); + return uri ? PlacesUIUtils.RDF.GetResource(uri) : null; + }, + + // nsITreeView + get rowCount() this._rows.length, + get selection() this._selection, + set selection(val) this._selection = val, + + getRowProperties: function() { return ""; }, + + getCellProperties: + function(aRow, aColumn) { + // for anonid-trees, we need to add the column-type manually + var props = ""; + let columnType = aColumn.element.getAttribute("anonid"); + if (columnType) + props += columnType; + else + columnType = aColumn.id; + + // Set the "ltr" property on url cells + if (columnType == "url") + props += " ltr"; + + if (columnType != "title") + return props; + + let node = this._getNodeForRow(aRow); + + if (this._cuttingNodes.has(node)) { + props += " cutting"; + } + + let properties = this._cellProperties.get(node); + if (properties === undefined) { + properties = ""; + let itemId = node.itemId; + let nodeType = node.type; + if (PlacesUtils.containerTypes.indexOf(nodeType) != -1) { + if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) { + properties += " query"; + if (PlacesUtils.nodeIsTagQuery(node)) + properties += " tagContainer"; + else if (PlacesUtils.nodeIsDay(node)) + properties += " dayContainer"; + else if (PlacesUtils.nodeIsHost(node)) + properties += " hostContainer"; + } + else if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER || + nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) { + if (this._controller.hasCachedLivemarkInfo(node)) { + properties += " livemark"; + } + else { + PlacesUtils.livemarks.getLivemark({ id: node.itemId }) + .then(aLivemark => { + this._controller.cacheLivemarkInfo(node, aLivemark); + let props = this._cellProperties.get(node); + this._cellProperties.set(node, props += " livemark"); + // The livemark attribute is set as a cell property on the title cell. + this._invalidateCellValue(node, this.COLUMN_TYPE_TITLE); + }, () => undefined); + } + } + + if (itemId != -1) { + let queryName = PlacesUIUtils.getLeftPaneQueryNameFromId(itemId); + if (queryName) + properties += " OrganizerQuery_" + queryName; + } + } + else if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) + properties += " separator"; + else if (PlacesUtils.nodeIsURI(node)) { + properties += " " + PlacesUIUtils.guessUrlSchemeForUI(node.uri); + + if (this._controller.hasCachedLivemarkInfo(node.parent)) { + properties += " livemarkItem"; + if (node.accessCount) { + properties += " visited"; + } + } + } + + this._cellProperties.set(node, properties); + } + + return props + " " + properties; + }, + + getColumnProperties: function(aColumn) { return ""; }, + + isContainer: function(aRow) { + // Only leaf nodes aren't listed in the rows array. + let node = this._rows[aRow]; + if (node === undefined) + return false; + + if (PlacesUtils.nodeIsContainer(node)) { + // Flat-lists may ignore expandQueries and other query options when + // they are asked to open a container. + if (this._flatList) + return true; + + // treat non-expandable childless queries as non-containers + if (PlacesUtils.nodeIsQuery(node)) { + let parent = node.parent; + if ((PlacesUtils.nodeIsQuery(parent) || + PlacesUtils.nodeIsFolder(parent)) && + !PlacesUtils.asQuery(node).hasChildren) + return PlacesUtils.asQuery(parent).queryOptions.expandQueries; + } + return true; + } + return false; + }, + + isContainerOpen: function(aRow) { + if (this._flatList) + return false; + + // All containers are listed in the rows array. + return this._rows[aRow].containerOpen; + }, + + isContainerEmpty: function(aRow) { + if (this._flatList) + return true; + + let node = this._rows[aRow]; + if (this._controller.hasCachedLivemarkInfo(node)) { + let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions; + return queryOptions.excludeItems; + } + + // All containers are listed in the rows array. + return !node.hasChildren; + }, + + isSeparator: function(aRow) { + // All separators are listed in the rows array. + let node = this._rows[aRow]; + return node && PlacesUtils.nodeIsSeparator(node); + }, + + isSorted: function() { + return this._result.sortingMode != + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + }, + + canDrop: function(aRow, aOrientation, aDataTransfer) { + if (!this._result) + throw Cr.NS_ERROR_UNEXPECTED; + + // Drop position into a sorted treeview would be wrong. + if (this.isSorted()) + return false; + + let ip = this._getInsertionPoint(aRow, aOrientation); + return ip && PlacesControllerDragHelper.canDrop(ip, aDataTransfer); + }, + + _getInsertionPoint: function(index, orientation) { + let container = this._result.root; + let dropNearItemId = -1; + // When there's no selection, assume the container is the container + // the view is populated from (i.e. the result's itemId). + if (index != -1) { + let lastSelected = this.nodeForTreeIndex(index); + if (this.isContainer(index) && orientation == Ci.nsITreeView.DROP_ON) { + // If the last selected item is an open container, append _into_ + // it, rather than insert adjacent to it. + container = lastSelected; + index = -1; + } + else if (lastSelected.containerOpen && + orientation == Ci.nsITreeView.DROP_AFTER && + lastSelected.hasChildren) { + // If the last selected node is an open container and the user is + // trying to drag into it as a first node, really insert into it. + container = lastSelected; + orientation = Ci.nsITreeView.DROP_ON; + index = 0; + } + else { + // Use the last-selected node's container. + container = lastSelected.parent; + + // During its Drag & Drop operation, the tree code closes-and-opens + // containers very often (part of the XUL "spring-loaded folders" + // implementation). And in certain cases, we may reach a closed + // container here. However, we can simply bail out when this happens, + // because we would then be back here in less than a millisecond, when + // the container had been reopened. + if (!container || !container.containerOpen) + return null; + + // Avoid the potentially expensive call to getChildIndex + // if we know this container doesn't allow insertion. + if (PlacesControllerDragHelper.disallowInsertion(container)) + return null; + + let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions; + if (queryOptions.sortingMode != + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) { + // If we are within a sorted view, insert at the end. + index = -1; + } + else if (queryOptions.excludeItems || + queryOptions.excludeQueries || + queryOptions.excludeReadOnlyFolders) { + // Some item may be invisible, insert near last selected one. + // We don't replace index here to avoid requests to the db, + // instead it will be calculated later by the controller. + index = -1; + dropNearItemId = lastSelected.itemId; + } + else { + let lsi = container.getChildIndex(lastSelected); + index = orientation == Ci.nsITreeView.DROP_BEFORE ? lsi : lsi + 1; + } + } + } + + if (PlacesControllerDragHelper.disallowInsertion(container)) + return null; + + return new InsertionPoint(PlacesUtils.getConcreteItemId(container), + index, orientation, + PlacesUtils.nodeIsTagQuery(container), + dropNearItemId); + }, + + drop: function(aRow, aOrientation, aDataTransfer) { + // We are responsible for translating the |index| and |orientation| + // parameters into a container id and index within the container, + // since this information is specific to the tree view. + let ip = this._getInsertionPoint(aRow, aOrientation); + if (ip) + PlacesControllerDragHelper.onDrop(ip, aDataTransfer); + + PlacesControllerDragHelper.currentDropTarget = null; + }, + + getParentIndex: function(aRow) { + let [parentNode, parentRow] = this._getParentByChildRow(aRow); + return parentRow; + }, + + hasNextSibling: function(aRow, aAfterIndex) { + if (aRow == this._rows.length - 1) { + // The last row has no sibling. + return false; + } + + let node = this._rows[aRow]; + if (node === undefined || this._isPlainContainer(node.parent)) { + // The node is a child of a plain container. + // If the next row is either unset or has the same parent, + // it's a sibling. + let nextNode = this._rows[aRow + 1]; + return (nextNode == undefined || nextNode.parent == node.parent); + } + + let thisLevel = node.indentLevel; + for (let i = aAfterIndex + 1; i < this._rows.length; ++i) { + let rowNode = this._getNodeForRow(i); + let nextLevel = rowNode.indentLevel; + if (nextLevel == thisLevel) + return true; + if (nextLevel < thisLevel) + break; + } + + return false; + }, + + getLevel: function(aRow) this._getNodeForRow(aRow).indentLevel, + + getImageSrc: function(aRow, aColumn) { + // Only the title column has an image. + if (this._getColumnType(aColumn) != this.COLUMN_TYPE_TITLE) + return ""; + + return this._getNodeForRow(aRow).icon; + }, + + getProgressMode: function(aRow, aColumn) { }, + getCellValue: function(aRow, aColumn) { }, + + getCellText: function(aRow, aColumn) { + let node = this._getNodeForRow(aRow); + switch (this._getColumnType(aColumn)) { + case this.COLUMN_TYPE_TITLE: + // normally, this is just the title, but we don't want empty items in + // the tree view so return a special string if the title is empty. + // Do it here so that callers can still get at the 0 length title + // if they go through the "result" API. + if (PlacesUtils.nodeIsSeparator(node)) + return ""; + return PlacesUIUtils.getBestTitle(node, true); + case this.COLUMN_TYPE_TAGS: + return node.tags; + case this.COLUMN_TYPE_URI: + if (PlacesUtils.nodeIsURI(node)) + return node.uri; + return ""; + case this.COLUMN_TYPE_DATE: + let nodeTime = node.time; + if (nodeTime == 0 || !PlacesUtils.nodeIsURI(node)) { + // hosts and days shouldn't have a value for the date column. + // Actually, you could argue this point, but looking at the + // results, seeing the most recently visited date is not what + // I expect, and gives me no information I know how to use. + // Only show this for URI-based items. + return ""; + } + + return this._convertPRTimeToString(nodeTime); + case this.COLUMN_TYPE_VISITCOUNT: + return node.accessCount; + case this.COLUMN_TYPE_KEYWORD: + if (PlacesUtils.nodeIsBookmark(node)) + return PlacesUtils.bookmarks.getKeywordForBookmark(node.itemId); + return ""; + case this.COLUMN_TYPE_DESCRIPTION: + if (node.itemId != -1) { + try { + return PlacesUtils.annotations. + getItemAnnotation(node.itemId, PlacesUIUtils.DESCRIPTION_ANNO); + } + catch (ex) { /* has no description */ } + } + return ""; + case this.COLUMN_TYPE_DATEADDED: + if (node.dateAdded) + return this._convertPRTimeToString(node.dateAdded); + return ""; + case this.COLUMN_TYPE_LASTMODIFIED: + if (node.lastModified) + return this._convertPRTimeToString(node.lastModified); + return ""; + case this.COLUMN_TYPE_PARENTFOLDER: + if (PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.queryType == + Components.interfaces.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY && node.uri) + return ""; + var bmsvc = Components.classes["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Components.interfaces.nsINavBookmarksService); + var rowId = node.itemId; + try { + var parentFolderId = bmsvc.getFolderIdForItem(rowId); + var folderTitle = bmsvc.getItemTitle(parentFolderId); + } catch(ex) { + var folderTitle = ""; + } + return folderTitle; + case this.COLUMN_TYPE_PARENTFOLDERPATH: + if (PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.queryType == + Components.interfaces.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY && node.uri) + return ""; + var bmsvc = Components.classes["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Components.interfaces.nsINavBookmarksService); + var rowId = node.itemId; + try { + var FolderId; + var parentFolderId = bmsvc.getFolderIdForItem(rowId); + var folderTitle = bmsvc.getItemTitle(parentFolderId); + while ((FolderId = bmsvc.getFolderIdForItem(parentFolderId))) { + if (FolderId == parentFolderId) + break; + parentFolderId = FolderId; + var text = bmsvc.getItemTitle(parentFolderId); + if (!text) + break; + folderTitle = text + " /"+ folderTitle; + } + folderTitle = folderTitle.replace(/^\s/,""); + } catch(ex) { + var folderTitle = ""; + } + return folderTitle; + } + return ""; + }, + + setTree: function(aTree) { + // If we are replacing the tree during a batch, there is a concrete risk + // that the treeView goes out of sync, thus it's safer to end the batch now. + // This is a no-op if we are not batching. + this.batching(false); + + let hasOldTree = this._tree != null; + this._tree = aTree; + + if (this._result) { + if (hasOldTree) { + // detach from result when we are detaching from the tree. + // This breaks the reference cycle between us and the result. + if (!aTree) { + this._result.removeObserver(this); + this._rootNode.containerOpen = false; + } + } + if (aTree) + this._finishInit(); + } + }, + + toggleOpenState: function(aRow) { + if (!this._result) + throw Cr.NS_ERROR_UNEXPECTED; + + let node = this._rows[aRow]; + if (this._flatList && this._openContainerCallback) { + this._openContainerCallback(node); + return; + } + + // Persist containers open status, but never persist livemarks. + if (!this._controller.hasCachedLivemarkInfo(node)) { + let resource = this._getResourceForNode(node); + if (resource) { + const openLiteral = PlacesUIUtils.RDF.GetResource("http://home.netscape.com/NC-rdf#open"); + const trueLiteral = PlacesUIUtils.RDF.GetLiteral("true"); + + if (node.containerOpen) + PlacesUIUtils.localStore.Unassert(resource, openLiteral, trueLiteral); + else + PlacesUIUtils.localStore.Assert(resource, openLiteral, trueLiteral, true); + } + } + + node.containerOpen = !node.containerOpen; + }, + + cycleHeader: function(aColumn) { + if (!this._result) + throw Cr.NS_ERROR_UNEXPECTED; + + // Sometimes you want a tri-state sorting, and sometimes you don't. This + // rule allows tri-state sorting when the root node is a folder. This will + // catch the most common cases. When you are looking at folders, you want + // the third state to reset the sorting to the natural bookmark order. When + // you are looking at history, that third state has no meaning so we try + // to disallow it. + // + // The problem occurs when you have a query that results in bookmark + // folders. One example of this is the subscriptions view. In these cases, + // this rule doesn't allow you to sort those sub-folders by their natural + // order. + let allowTriState = PlacesUtils.nodeIsFolder(this._result.root); + + let oldSort = this._result.sortingMode; + let oldSortingAnnotation = this._result.sortingAnnotation; + let newSort; + let newSortingAnnotation = ""; + const NHQO = Ci.nsINavHistoryQueryOptions; + switch (this._getColumnType(aColumn)) { + case this.COLUMN_TYPE_TITLE: + if (oldSort == NHQO.SORT_BY_TITLE_ASCENDING) + newSort = NHQO.SORT_BY_TITLE_DESCENDING; + else if (allowTriState && oldSort == NHQO.SORT_BY_TITLE_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_TITLE_ASCENDING; + + break; + case this.COLUMN_TYPE_URI: + if (oldSort == NHQO.SORT_BY_URI_ASCENDING) + newSort = NHQO.SORT_BY_URI_DESCENDING; + else if (allowTriState && oldSort == NHQO.SORT_BY_URI_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_URI_ASCENDING; + + break; + case this.COLUMN_TYPE_DATE: + if (oldSort == NHQO.SORT_BY_DATE_ASCENDING) + newSort = NHQO.SORT_BY_DATE_DESCENDING; + else if (allowTriState && + oldSort == NHQO.SORT_BY_DATE_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_DATE_ASCENDING; + + break; + case this.COLUMN_TYPE_VISITCOUNT: + // visit count default is unusual because we sort by descending + // by default because you are most likely to be looking for + // highly visited sites when you click it + if (oldSort == NHQO.SORT_BY_VISITCOUNT_DESCENDING) + newSort = NHQO.SORT_BY_VISITCOUNT_ASCENDING; + else if (allowTriState && oldSort == NHQO.SORT_BY_VISITCOUNT_ASCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_VISITCOUNT_DESCENDING; + + break; + case this.COLUMN_TYPE_KEYWORD: + if (oldSort == NHQO.SORT_BY_KEYWORD_ASCENDING) + newSort = NHQO.SORT_BY_KEYWORD_DESCENDING; + else if (allowTriState && oldSort == NHQO.SORT_BY_KEYWORD_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_KEYWORD_ASCENDING; + + break; + case this.COLUMN_TYPE_DESCRIPTION: + if (oldSort == NHQO.SORT_BY_ANNOTATION_ASCENDING && + oldSortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) { + newSort = NHQO.SORT_BY_ANNOTATION_DESCENDING; + newSortingAnnotation = PlacesUIUtils.DESCRIPTION_ANNO; + } + else if (allowTriState && + oldSort == NHQO.SORT_BY_ANNOTATION_DESCENDING && + oldSortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) + newSort = NHQO.SORT_BY_NONE; + else { + newSort = NHQO.SORT_BY_ANNOTATION_ASCENDING; + newSortingAnnotation = PlacesUIUtils.DESCRIPTION_ANNO; + } + + break; + case this.COLUMN_TYPE_DATEADDED: + if (oldSort == NHQO.SORT_BY_DATEADDED_ASCENDING) + newSort = NHQO.SORT_BY_DATEADDED_DESCENDING; + else if (allowTriState && + oldSort == NHQO.SORT_BY_DATEADDED_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_DATEADDED_ASCENDING; + + break; + case this.COLUMN_TYPE_LASTMODIFIED: + if (oldSort == NHQO.SORT_BY_LASTMODIFIED_ASCENDING) + newSort = NHQO.SORT_BY_LASTMODIFIED_DESCENDING; + else if (allowTriState && + oldSort == NHQO.SORT_BY_LASTMODIFIED_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_LASTMODIFIED_ASCENDING; + + break; + case this.COLUMN_TYPE_TAGS: + if (oldSort == NHQO.SORT_BY_TAGS_ASCENDING) + newSort = NHQO.SORT_BY_TAGS_DESCENDING; + else if (allowTriState && oldSort == NHQO.SORT_BY_TAGS_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_TAGS_ASCENDING; + + break; + case this.COLUMN_TYPE_PARENTFOLDER: + return; + + break; + case this.COLUMN_TYPE_PARENTFOLDERPATH: + return; + + break; + default: + throw Cr.NS_ERROR_INVALID_ARG; + } + this._result.sortingAnnotation = newSortingAnnotation; + this._result.sortingMode = newSort; + }, + + isEditable: function(aRow, aColumn) { + // At this point we only support editing the title field. + if (aColumn.index != 0) + return false; + + let node = this._rows[aRow]; + if (!node) { + Cu.reportError("isEditable called for an unbuilt row."); + return false; + } + let itemId = node.itemId; + + // Only bookmark-nodes are editable. Fortunately, this check also takes + // care of livemark children. + if (itemId == -1) + return false; + + // The following items are also not editable, even though they are bookmark + // items. + // * places-roots + // * the left pane special folders and queries (those are place: uri + // bookmarks) + // * separators + // + // Note that concrete itemIds aren't used intentionally. For example, we + // have no reason to disallow renaming a shortcut to the Bookmarks Toolbar, + // except for the one under All Bookmarks. + if (PlacesUtils.nodeIsSeparator(node) || PlacesUtils.isRootItem(itemId)) + return false; + + let parentId = PlacesUtils.getConcreteItemId(node.parent); + if (parentId == PlacesUIUtils.leftPaneFolderId || + parentId == PlacesUIUtils.allBookmarksFolderId) { + // Note that the for the time being this is the check that actually + // blocks renaming places "roots", and not the isRootItem check above. + // That's because places root are only exposed through folder shortcuts + // descendants of the left pane folder. + return false; + } + + return true; + }, + + setCellText: function(aRow, aColumn, aText) { + // We may only get here if the cell is editable. + let node = this._rows[aRow]; + if (node.title != aText) { + let txn = new PlacesEditItemTitleTransaction(node.itemId, aText); + PlacesUtils.transactionManager.doTransaction(txn); + } + }, + + toggleCutNode: function(aNode, aValue) { + let currentVal = this._cuttingNodes.has(aNode); + if (currentVal != aValue) { + if (aValue) + this._cuttingNodes.add(aNode); + else + this._cuttingNodes.delete(aNode); + + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + } + }, + + selectionChanged: function() { }, + cycleCell: function(aRow, aColumn) { }, + isSelectable: function(aRow, aColumn) { return false; }, + performAction: function(aAction) { }, + performActionOnRow: function(aAction, aRow) { }, + performActionOnCell: function(aAction, aRow, aColumn) { } +}; diff --git a/browser/components/places/jar.mn b/browser/components/places/jar.mn new file mode 100644 index 000000000..77d05663a --- /dev/null +++ b/browser/components/places/jar.mn @@ -0,0 +1,34 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +browser.jar: +% overlay chrome://browser/content/places/places.xul chrome://browser/content/places/downloadsViewOverlay.xul +# Provide another URI for the bookmarkProperties dialog so we can persist the +# attributes separately + content/browser/places/bookmarkProperties2.xul (content/bookmarkProperties.xul) +* content/browser/places/places.xul (content/places.xul) + content/browser/places/places.js (content/places.js) + content/browser/places/places.css (content/places.css) + content/browser/places/organizer.css (content/organizer.css) + content/browser/places/bookmarkProperties.xul (content/bookmarkProperties.xul) + content/browser/places/bookmarkProperties.js (content/bookmarkProperties.js) + content/browser/places/placesOverlay.xul (content/placesOverlay.xul) + content/browser/places/menu.xml (content/menu.xml) + content/browser/places/tree.xml (content/tree.xml) + content/browser/places/controller.js (content/controller.js) + content/browser/places/treeView.js (content/treeView.js) + content/browser/places/browserPlacesViews.js (content/browserPlacesViews.js) +# keep the Places version of the history sidebar at history/history-panel.xul +# to prevent having to worry about between versions of the browser + content/browser/history/history-panel.xul (content/history-panel.xul) + content/browser/places/history-panel.js (content/history-panel.js) +# ditto for the bookmarks sidebar + content/browser/bookmarks/bookmarksPanel.xul (content/bookmarksPanel.xul) + content/browser/bookmarks/bookmarksPanel.js (content/bookmarksPanel.js) + content/browser/bookmarks/sidebarUtils.js (content/sidebarUtils.js) + content/browser/places/moveBookmarks.xul (content/moveBookmarks.xul) + content/browser/places/moveBookmarks.js (content/moveBookmarks.js) + content/browser/places/editBookmarkOverlay.xul (content/editBookmarkOverlay.xul) + content/browser/places/editBookmarkOverlay.js (content/editBookmarkOverlay.js) + content/browser/places/downloadsViewOverlay.xul (content/downloadsViewOverlay.xul) diff --git a/browser/components/places/moz.build b/browser/components/places/moz.build new file mode 100644 index 000000000..8d85e2b76 --- /dev/null +++ b/browser/components/places/moz.build @@ -0,0 +1,8 @@ +# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +JAR_MANIFESTS += ['jar.mn'] + +EXTRA_JS_MODULES += [ 'PlacesUIUtils.jsm' ] |