gecko-dev/browser/components/places/content/controller.js
David P c5f96564f5 Bug 1893119: Part 6 - Make JS clients of nsIDragService::GetCurrentSession use a (implicit) window a=diannaS
GetCurrentSession now needs to know the widget to return the drag
session in content processes.  It now uses an explicitly provided
window or the entry global JS context (that must be a window).

This also adds GetDragSession to DOMWindowUtils, and fixes some small
bugs in EventUtils.

Original Revision: https://phabricator.services.mozilla.com/D211064

Differential Revision: https://phabricator.services.mozilla.com/D221159
2024-09-06 14:44:12 +00:00

1750 lines
56 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */
ChromeUtils.defineESModuleGetters(this, {
PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs",
PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
});
/* import-globals-from /browser/base/content/utilityOverlay.js */
/* import-globals-from ./places.js */
/**
* Represents an insertion point within a container where we can insert
* items.
*
* @param {object} options an object containing the following properties:
* @param {string} options.parentGuid
* The unique identifier of the parent container
* @param {number} [options.index]
* The index within the container where to insert, defaults to appending
* @param {number} [options.orientation]
* 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! Defaults to DROP_ON.
* @param {string} [options.tagName]
* The tag name if this IP is set to a tag, null otherwise.
* @param {*} [options.dropNearNode]
* When defined index will be calculated based on this node
*/
function PlacesInsertionPoint({
parentGuid,
index = PlacesUtils.bookmarks.DEFAULT_INDEX,
orientation = Ci.nsITreeView.DROP_ON,
tagName = null,
dropNearNode = null,
}) {
this.guid = parentGuid;
this._index = index;
this.orientation = orientation;
this.tagName = tagName;
this.dropNearNode = dropNearNode;
}
PlacesInsertionPoint.prototype = {
set index(val) {
this._index = val;
},
async getIndex() {
if (this.dropNearNode) {
// If dropNearNode is set up we must calculate the index of the item near
// which we will drop.
let index = (
await PlacesUtils.bookmarks.fetch(this.dropNearNode.bookmarkGuid)
).index;
return this.orientation == Ci.nsITreeView.DROP_BEFORE ? index : index + 1;
}
return this._index;
},
get isTag() {
return typeof this.tagName == "string";
},
};
/**
* Places Controller
*/
function PlacesController(aView) {
this._view = aView;
ChromeUtils.defineLazyGetter(this, "profileName", function () {
return Services.dirsvc.get("ProfD", Ci.nsIFile).leafName;
});
XPCOMUtils.defineLazyPreferenceGetter(
this,
"forgetSiteClearByBaseDomain",
"places.forgetThisSite.clearByBaseDomain",
false
);
ChromeUtils.defineESModuleGetters(this, {
ForgetAboutSite: "resource://gre/modules/ForgetAboutSite.sys.mjs",
});
}
PlacesController.prototype = {
/**
* The places view.
*/
_view: null,
// This is used in certain views to disable user actions on the places tree
// views. This avoids accidental deletion/modification when the user is not
// actually organising the trees.
disableUserActions: false,
QueryInterface: ChromeUtils.generateQI(["nsIClipboardOwner"]),
// nsIClipboardOwner
LosingOwnership: function PC_LosingOwnership() {
this.cutNodes = [];
},
terminate: function PC_terminate() {
this._releaseClipboardOwnership();
},
supportsCommand: function PC_supportsCommand(aCommand) {
if (this.disableUserActions) {
return false;
}
// 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 PC_isCommandEnabled(aCommand) {
// Determine whether or not nodes can be inserted.
let ip = this._view.insertionPoint;
let canInsert = ip && (aCommand.endsWith("_paste") || !ip.isTag);
switch (aCommand) {
case "cmd_undo":
return PlacesTransactions.topUndoEntry != null;
case "cmd_redo":
return PlacesTransactions.topRedoEntry != null;
case "cmd_cut":
case "placesCmd_cut":
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 the cmd_delete check.
case "cmd_delete":
case "placesCmd_delete":
case "placesCmd_deleteDataHost":
return this._hasRemovableSelection();
case "cmd_copy":
case "placesCmd_copy":
case "placesCmd_showInFolder":
return this._view.hasSelection;
case "cmd_paste":
case "placesCmd_paste":
// If the clipboard contains a Places flavor it is definitely pasteable,
// otherwise we also allow pasting "text/plain" and "text/x-moz-url" data.
// We don't check if the data is valid here, because the clipboard may
// contain very large blobs that would largely slowdown commands updating.
// Of course later paste() should ignore any invalid data.
return (
canInsert &&
Services.clipboard.hasDataMatchingFlavors(
[
...PlacesUIUtils.PLACES_FLAVORS,
PlacesUtils.TYPE_X_MOZ_URL,
PlacesUtils.TYPE_PLAINTEXT,
],
Ci.nsIClipboard.kGlobalClipboard
)
);
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": {
let selectedNode = this._view.selectedNode;
return selectedNode && PlacesUtils.nodeIsURI(selectedNode);
}
case "placesCmd_new:folder":
return canInsert;
case "placesCmd_new:bookmark":
return canInsert;
case "placesCmd_new:separator":
return (
canInsert &&
!PlacesUtils.asQuery(this._view.result.root).queryOptions
.excludeItems &&
this._view.result.sortingMode ==
Ci.nsINavHistoryQueryOptions.SORT_BY_NONE
);
case "placesCmd_show:info": {
let selectedNode = this._view.selectedNode;
return (
selectedNode &&
!PlacesUtils.isRootItem(
PlacesUtils.getConcreteItemGuid(selectedNode)
) &&
(PlacesUtils.nodeIsTagQuery(selectedNode) ||
PlacesUtils.nodeIsBookmark(selectedNode) ||
(PlacesUtils.nodeIsFolder(selectedNode) &&
!PlacesUtils.isQueryGeneratedFolder(selectedNode)))
);
}
case "placesCmd_sortBy:name": {
let selectedNode = this._view.selectedNode;
return (
selectedNode &&
PlacesUtils.nodeIsFolder(selectedNode) &&
!PlacesUIUtils.isFolderReadOnly(selectedNode) &&
this._view.result.sortingMode ==
Ci.nsINavHistoryQueryOptions.SORT_BY_NONE
);
}
case "placesCmd_createBookmark": {
return !this._view.selectedNodes.some(
node => !PlacesUtils.nodeIsURI(node) || node.itemId != -1
);
}
default:
return false;
}
},
doCommand: function PC_doCommand(aCommand) {
if (aCommand != "cmd_delete" && aCommand != "placesCmd_delete") {
// Clear out last removal fingerprint if any other commands arrives.
// This covers sequences like: remove, undo, remove, where the removal
// commands are not immediately adjacent.
this._lastRemoveOperationFingerprint = null;
}
switch (aCommand) {
case "cmd_undo":
PlacesTransactions.undo().catch(console.error);
break;
case "cmd_redo":
PlacesTransactions.redo().catch(console.error);
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().catch(console.error);
break;
case "cmd_delete":
case "placesCmd_delete":
this.remove("Remove Selection").catch(console.error);
break;
case "placesCmd_deleteDataHost":
this.forgetAboutThisSite().catch(console.error);
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").catch(console.error);
break;
case "placesCmd_new:bookmark":
this.newItem("bookmark").catch(console.error);
break;
case "placesCmd_new:separator":
this.newSeparator().catch(console.error);
break;
case "placesCmd_show:info":
this.showBookmarkPropertiesForSelection();
break;
case "placesCmd_sortBy:name":
this.sortFolderByName().catch(console.error);
break;
case "placesCmd_createBookmark": {
const nodes = this._view.selectedNodes.map(node => {
return {
uri: Services.io.newURI(node.uri),
title: node.title,
};
});
PlacesUIUtils.showBookmarkPagesDialog(
nodes,
["keyword", "location"],
window.top
);
break;
}
case "placesCmd_showInFolder":
this.showInFolder(this._view.selectedNode.bookmarkGuid);
break;
}
},
onEvent: function PC_onEvent() {},
/**
* 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 {boolean} 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;
},
/**
* This helper can be used to avoid handling repeated remove operations.
* Clear this._lastRemoveOperationFingerprint if another operation happens.
*
* @returns {boolean} whether the removal is the same as the last one.
*/
_isRepeatedRemoveOperation() {
let lastRemoveOperationFingerprint = this._lastRemoveOperationFingerprint;
// .bookmarkGuid and .pageGuid may either be null or an empty string. While
// that should probably change, it's safer to use || here.
this._lastRemoveOperationFingerprint = PlacesUtils.sha256(
this._view.selectedNodes
.map(n => n.bookmarkGuid || (n.pageGuid || n.uri) + n.time)
.join()
);
return (
lastRemoveOperationFingerprint == this._lastRemoveOperationFingerprint
);
},
/**
* Gathers information about the selected nodes according to the following
* rules:
* "link" node is a URI
* "bookmark" node is a bookmark
* "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 {Array} 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() {
return this._view.selectedNodes.map(n => this._selectionMetadataForNode(n));
},
_selectionMetadataForNode(node) {
let nodeData = {};
// We don't use the nodeIs* methods here to avoid going through the type
// property way too often
switch (node.type) {
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.query_host = true;
break;
case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY:
case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY:
nodeData.query_day = true;
break;
case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT:
nodeData.query_tag = true;
}
}
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;
if (PlacesUtils.nodeIsBookmark(node)) {
nodeData.link_bookmark = true;
var parentNode = node.parent;
if (parentNode && PlacesUtils.nodeIsTagQuery(parentNode)) {
nodeData.link_bookmark_tag = true;
}
}
break;
}
return nodeData;
},
/**
* Determines if a context-menu item should be shown
*
* @param {object} aMenuItem
* the context menu item
* @param {object} aMetaData
* meta data about the selection
* @returns {boolean} true if the conditions (see buildContextMenu) are satisfied
* and the item can be displayed, false otherwise.
*/
_shouldShowMenuItem(aMenuItem, aMetaData) {
if (
aMenuItem.hasAttribute("hide-if-private-browsing") &&
!PrivateBrowsingUtils.enabled
) {
return false;
}
if (
aMenuItem.hasAttribute("hide-if-usercontext-disabled") &&
!Services.prefs.getBoolPref("privacy.userContext.enabled", false)
) {
return false;
}
let selectiontype =
aMenuItem.getAttribute("selection-type") || "single|multiple";
var selectionTypes = selectiontype.split("|");
if (selectionTypes.includes("any")) {
return true;
}
var count = aMetaData.length;
if (count > 1 && !selectionTypes.includes("multiple")) {
return false;
}
if (count == 1 && !selectionTypes.includes("single")) {
return false;
}
// If there is no selection and selectionType doesn't include `none`
// hide the item, otherwise try to use the root node to extract valid
// metadata to compare against.
if (count == 0) {
if (!selectionTypes.includes("none")) {
return false;
}
aMetaData = [this._selectionMetadataForNode(this._view.result.root)];
}
let attr = aMenuItem.getAttribute("hide-if-node-type");
if (attr) {
let rules = attr.split("|");
if (aMetaData.some(d => rules.some(r => r in d))) {
return false;
}
}
attr = aMenuItem.getAttribute("hide-if-node-type-is-only");
if (attr) {
let rules = attr.split("|");
if (rules.some(r => aMetaData.every(d => r in d))) {
return false;
}
}
attr = aMenuItem.getAttribute("node-type");
if (!attr) {
return true;
}
let anyMatched = false;
let rules = attr.split("|");
for (let metaData of aMetaData) {
if (rules.some(r => r in metaData)) {
anyMatched = true;
} else {
return false;
}
}
return anyMatched;
},
/**
* Uses meta-data rules set as attributes on the menuitems, representing the
* current selection in the view (see `_buildSelectionMetadata`) and sets the
* visibility state for each menuitem according to the following rules:
* 1) The visibility state is unchanged if none of the attributes are set.
* 2) Attributes should not be set on menuseparators.
* 3) The boolean `ignore-item` attribute may be set when this code should
* not handle that menuitem.
* 4) The `selection-type` attribute may be set to:
* - `single` if it should be visible only when there is a single node
* selected
* - `multiple` if it should be visible only when multiple nodes are
* selected
* - `none` if it should be visible when there are no selected nodes
* - `any` if it should be visible for any kind of selection
* - a `|` separated combination of the above.
* 5) The `node-type` attribute may be set to values representing the
* type of the node triggering the context menu. The menuitem will be
* visible when one of the rules (separated by `|`) matches.
* In case of multiple selection, the menuitem is visible only if all of
* the selected nodes match one of the rule.
* 6) The `hide-if-node-type` accepts the same rules as `node-type`, but
* hides the menuitem if the nodes match at least one of the rules.
* It takes priority over `nodetype`.
* 7) The `hide-if-node-type-is-only` accepts the same rules as `node-type`, but
* hides the menuitem if any of the rules match all of the nodes.
* 8) The boolean `hide-if-no-insertion-point` attribute may be set to hide a
* menuitem when there's no insertion point. An insertion point represents
* a point in the view where a new item can be inserted.
* 9) The boolean `hide-if-private-browsing` attribute may be set to hide a
* menuitem in private browsing mode
* 10) The boolean `hide-if-single-click-opens` attribute may be set to hide a
* menuitem in views opening entries with a single click.
*
* @param {object} aPopup
* The menupopup to build children into.
* @returns {boolean} true if at least one item is visible, false otherwise.
*/
buildContextMenu(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.children.length; ++i) {
var item = aPopup.children[i];
if (item.getAttribute("ignore-item") == "true") {
continue;
}
if (item.localName != "menuseparator") {
// We allow pasting into tag containers, so special case that.
let hideIfNoIP =
item.getAttribute("hide-if-no-insertion-point") == "true" &&
noIp &&
!(ip && ip.isTag && item.id == "placesContext_paste");
let hideIfPrivate =
item.getAttribute("hide-if-private-browsing") == "true" &&
PrivateBrowsingUtils.isWindowPrivate(window);
// Hide `Open` if the primary action on click is opening.
let hideIfSingleClickOpens =
item.getAttribute("hide-if-single-click-opens") == "true" &&
!PlacesUIUtils.loadBookmarksInBackground &&
!PlacesUIUtils.loadBookmarksInTabs &&
this._view.singleClickOpens;
let hideIfNotSearch =
item.getAttribute("hide-if-not-search") == "true" &&
(!this._view.selectedNode ||
!this._view.selectedNode.parent ||
!PlacesUtils.nodeIsQuery(this._view.selectedNode.parent));
let shouldHideItem =
hideIfNoIP ||
hideIfPrivate ||
hideIfSingleClickOpens ||
hideIfNotSearch ||
!this._shouldShowMenuItem(item, metadata);
item.hidden = shouldHideItem;
item.disabled =
shouldHideItem || item.getAttribute("start-disabled") == "true";
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;
}
if (item.id === "placesContext_deleteBookmark") {
document.l10n.setAttributes(item, "places-delete-bookmark", {
count: metadata.length,
});
}
if (item.id === "placesContext_deleteFolder") {
document.l10n.setAttributes(item, "places-delete-folder", {
count: metadata.length,
});
}
}
// Set Open Folder/Links In Tabs or Open Bookmark item's enabled state if they're visible
if (usableItemCount > 0) {
let openContainerInTabsItem = document.getElementById(
"placesContext_openContainer:tabs"
);
let openBookmarksItem = document.getElementById(
"placesContext_openBookmarkContainer:tabs"
);
for (let menuItem of [openContainerInTabsItem, openBookmarksItem]) {
if (!menuItem.hidden) {
var containerToUse =
this._view.selectedNode || this._view.result.root;
if (PlacesUtils.nodeIsContainer(containerToUse)) {
if (!PlacesUtils.hasChildURIs(containerToUse)) {
menuItem.disabled = true;
// Ensure that we don't display the menu if nothing is enabled:
usableItemCount--;
}
}
}
}
}
const deleteHistoryItem = document.getElementById(
"placesContext_delete_history"
);
document.l10n.setAttributes(deleteHistoryItem, "places-delete-page", {
count: metadata.length,
});
const createBookmarkItem = document.getElementById(
"placesContext_createBookmark"
);
document.l10n.setAttributes(createBookmarkItem, "places-create-bookmark", {
count: metadata.length,
});
return usableItemCount > 0;
},
/**
* Select all links in the current view.
*/
selectAll: function PC_selectAll() {
this._view.selectAll();
},
/**
* Opens the bookmark properties for the selected URI Node.
*/
showBookmarkPropertiesForSelection() {
let node = this._view.selectedNode;
if (!node) {
return;
}
PlacesUIUtils.showBookmarkDialog(
{ action: "edit", node, hiddenRows: ["folderPicker"] },
window.top
);
},
/**
* Opens the links in the selected folder, or the selected links in new tabs.
*
* @param {object} aEvent
* The associated event.
*/
openSelectionInTabs: function PC_openLinksInTabs(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;
}
PlacesUIUtils.openMultipleLinksInTabs(
node ? node : nodes,
aEvent,
this._view
);
},
/**
* Shows the Add Bookmark UI for the current insertion point.
*
* @param {string} aType
* the type of the new item (bookmark/folder)
*/
async newItem(aType) {
let ip = this._view.insertionPoint;
if (!ip) {
throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
}
let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog(
{
action: "add",
type: aType,
defaultInsertionPoint: ip,
hiddenRows: ["folderPicker"],
},
window.top
);
if (bookmarkGuid) {
this._view.selectItems([bookmarkGuid], false);
}
},
/**
* Create a new Bookmark separator somewhere.
*/
async newSeparator() {
var ip = this._view.insertionPoint;
if (!ip) {
throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
}
let index = await ip.getIndex();
let txn = PlacesTransactions.NewSeparator({ parentGuid: ip.guid, index });
let guid = await txn.transact();
// Select the new item.
this._view.selectItems([guid], false);
},
/**
* Sort the selected folder by name
*/
async sortFolderByName() {
let guid = PlacesUtils.getConcreteItemGuid(this._view.selectedNode);
await PlacesTransactions.SortByName(guid).transact();
},
/**
* 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 {object} node
* Node to check for containment.
* @param {Array} pastFolders
* List of folders the calling function has already traversed
* @returns {boolean} true if the node should be skipped, false otherwise.
*/
_shouldSkipNode: function PC_shouldSkipNode(node, pastFolders) {
/**
* Determines if a node is contained by another node within a resultset.
*
* @param {object} parent
* The parent container to check for containment in
* @returns {boolean} true if node is a member of parent's children, false otherwise.
*/
function isNodeContainedBy(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 (isNodeContainedBy(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 {Array} range
* An array of nodes to remove. Should all be adjacent.
* @param {Array} transactions
* An array of transactions (returned)
* @param {Array} [removedFolders]
* An array of folder nodes that have already been removed.
* @returns {number} The total number of items affected.
*/
async _removeRange(range, transactions, removedFolders) {
if (!(transactions instanceof Array)) {
throw new Error("Must pass a transactions array");
}
if (!removedFolders) {
removedFolders = [];
}
let bmGuidsToRemove = [];
let totalItems = 0;
for (var i = 0; i < range.length; ++i) {
var node = range[i];
if (this._shouldSkipNode(node, removedFolders)) {
continue;
}
totalItems++;
if (PlacesUtils.nodeIsTagQuery(node.parent)) {
// This is a uri node inside a tag container. It needs a special
// untag transaction.
let tag = node.parent.title || "";
if (!tag) {
// The parent may be the root node, that doesn't have a title.
tag = node.parent.query.tags[0];
}
transactions.push(PlacesTransactions.Untag({ urls: [node.uri], tag }));
} else if (
PlacesUtils.nodeIsTagQuery(node) &&
node.parent &&
PlacesUtils.nodeIsQuery(node.parent) &&
PlacesUtils.asQuery(node.parent).queryOptions.resultType ==
Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT
) {
// 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.
let tag = node.title;
let urls = new Set();
await PlacesUtils.bookmarks.fetch({ tags: [tag] }, b =>
urls.add(b.url)
);
transactions.push(
PlacesTransactions.Untag({ tag, urls: Array.from(urls) })
);
} 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.
await PlacesUtils.history.remove(node.uri).catch(console.error);
// 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.
await this._removeHistoryContainer(node).catch(console.error);
// 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);
}
bmGuidsToRemove.push(node.bookmarkGuid);
}
}
if (bmGuidsToRemove.length) {
transactions.push(PlacesTransactions.Remove({ guids: bmGuidsToRemove }));
}
return totalItems;
},
async _removeRowsFromBookmarks() {
let ranges = this._view.removableSelectionRanges;
let transactions = [];
let removedFolders = [];
let totalItems = 0;
for (let range of ranges) {
totalItems += await this._removeRange(
range,
transactions,
removedFolders
);
}
if (transactions.length) {
await PlacesUIUtils.batchUpdatesForNode(
this._view.result,
totalItems,
async () => {
await PlacesTransactions.batch(
transactions,
"PlacesController::removeRowsFromBookmarks"
);
}
);
}
},
/**
* Removes the set of selected ranges from history, asynchronously. History
* deletes are not undoable.
*/
async _removeRowsFromHistory() {
let nodes = this._view.selectedNodes;
let URIs = new Set();
for (let i = 0; i < nodes.length; ++i) {
let node = nodes[i];
if (PlacesUtils.nodeIsURI(node)) {
URIs.add(node.uri);
} else if (
PlacesUtils.nodeIsQuery(node) &&
PlacesUtils.asQuery(node).queryOptions.queryType ==
Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY
) {
await this._removeHistoryContainer(node).catch(console.error);
}
}
if (URIs.size) {
await PlacesUIUtils.batchUpdatesForNode(
this._view.result,
URIs.size,
async () => {
await PlacesUtils.history.remove([...URIs]);
}
);
}
},
/**
* Removes history visits for an history container node. History deletes are
* not undoable.
*
* @param {object} aContainerNode
* The container node to remove.
*/
async _removeHistoryContainer(aContainerNode) {
if (PlacesUtils.nodeIsHost(aContainerNode)) {
// This is a site container.
// Check if it's the container for local files (don't be fooled by the
// bogus string name, this is "(local files)").
let host =
"." +
(aContainerNode.title == PlacesUtils.getString("localhost")
? ""
: aContainerNode.title);
// Will update faster if all children hidden before removing
aContainerNode.containerOpen = false;
await PlacesUtils.history.removeByFilter({ host });
} else if (PlacesUtils.nodeIsDay(aContainerNode)) {
// This is a day container.
let query = aContainerNode.query;
let beginTime = query.beginTime;
let endTime = query.endTime;
if (!query || !beginTime || !endTime) {
throw new Error("A valid date container query should exist!");
}
// Will update faster if all children hidden before removing
aContainerNode.containerOpen = false;
// 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.
await PlacesUtils.history.removeByFilter({
beginDate: PlacesUtils.toDate(beginTime + 1000),
endDate: PlacesUtils.toDate(endTime),
});
}
},
/**
* Removes the selection
*/
async remove() {
if (!this._hasRemovableSelection()) {
return;
}
// Sometimes we get repeated remove operation requests, because the user is
// holding down the DEL key. Since removal operations are asynchronous
// that would cause duplicated remove transactions that perform badly,
// increase memory usage (duplicate data), and cause failures (trying to
// act on already removed nodes).
if (this._isRepeatedRemoveOperation()) {
return;
}
var root = this._view.result.root;
if (PlacesUtils.nodeIsFolder(root)) {
await this._removeRowsFromBookmarks();
} else if (PlacesUtils.nodeIsQuery(root)) {
var queryType = PlacesUtils.asQuery(root).queryOptions.queryType;
if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS) {
await this._removeRowsFromBookmarks();
} else if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) {
await this._removeRowsFromHistory();
} else {
throw new Error("Unknown query type");
}
} else {
throw new Error("unexpected root");
}
},
/**
* Fills a DataTransfer object with the content of the selection that can be
* dropped elsewhere.
*
* @param {object} aEvent
* The dragstart event.
*/
setDataTransfer: function PC_setDataTransfer(aEvent) {
let dt = aEvent.dataTransfer;
let result = this._view.result;
let didSuppressNotifications = result.suppressNotifications;
if (!didSuppressNotifications) {
result.suppressNotifications = true;
}
function addData(type, index) {
let wrapNode = PlacesUtils.wrapNode(node, type);
dt.mozSetDataAt(type, wrapNode, index);
}
function addURIData(index) {
addData(PlacesUtils.TYPE_X_MOZ_URL, index);
addData(PlacesUtils.TYPE_PLAINTEXT, index);
addData(PlacesUtils.TYPE_HTML, index);
}
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);
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);
Services.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 PC__releaseClipboardOwnership() {
if (this.cutNodes.length) {
// This clears the logical clipboard, doesn't remove data.
Services.clipboard.emptyClipboard(Ci.nsIClipboard.kGlobalClipboard);
}
},
_clearClipboard: function PC__clearClipboard() {
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(""));
Services.clipboard.setData(
xferable,
null,
Ci.nsIClipboard.kGlobalClipboard
);
},
_populateClipboard: function PC__populateClipboard(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_PLAINTEXT, 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);
}
contents.forEach(function (content) {
content.entries.push(PlacesUtils.wrapNode(node, content.type));
});
}, this);
function addData(type, data) {
xferable.addDataFlavor(type);
xferable.setTransferData(type, PlacesUtils.toISupportsString(data));
}
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) {
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) {
Services.clipboard.setData(
xferable,
aAction == "cut" ? this : null,
Ci.nsIClipboard.kGlobalClipboard
);
}
},
_cutNodes: [],
get cutNodes() {
return 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);
},
/**
* Copy Bookmarks and Folders to the clipboard
*/
copy: function PC_copy() {
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 PC_cut() {
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
*/
async paste() {
// No reason to proceed if there isn't a valid insertion point.
let ip = this._view.insertionPoint;
if (!ip) {
throw Components.Exception("", 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_PLAINTEXT,
].forEach(type => xferable.addDataFlavor(type));
Services.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 doCopy = action == "copy";
let itemsToSelect = await PlacesUIUtils.handleTransferItems(
items,
ip,
doCopy,
this._view
);
// Cut/past operations are not repeatable, so clear the clipboard.
if (action == "cut") {
this._clearClipboard();
}
if (itemsToSelect.length) {
this._view.selectItems(itemsToSelect, false);
}
},
/**
* Checks if we can insert into a container.
*
* @param {object} container
* The container were we are want to drop
* @returns {boolean}
*/
disallowInsertion(container) {
if (!container) {
throw new Error("empty container");
}
// Allow dropping into Tag containers and editable folders.
return (
!PlacesUtils.nodeIsTagQuery(container) &&
(!PlacesUtils.nodeIsFolder(container) ||
PlacesUIUtils.isFolderReadOnly(container))
);
},
/**
* Determines if a node can be moved.
*
* @param {object} node
* A nsINavHistoryResultNode node.
* @returns {boolean} True if the node can be moved, false otherwise.
*/
canMoveNode(node) {
// Only bookmark items are movable.
if (node.itemId == -1) {
return false;
}
// Once tags and bookmarked are divorced, the tag-query check should be
// removed.
let parentNode = node.parent;
if (!parentNode) {
return false;
}
// Once tags and bookmarked are divorced, the tag-query check should be
// removed.
if (PlacesUtils.nodeIsTagQuery(parentNode)) {
return false;
}
return (
(PlacesUtils.nodeIsFolder(parentNode) &&
!PlacesUIUtils.isFolderReadOnly(parentNode)) ||
PlacesUtils.nodeIsQuery(parentNode)
);
},
async forgetAboutThisSite() {
let host;
if (PlacesUtils.nodeIsHost(this._view.selectedNode)) {
host = this._view.selectedNode.query.domain;
} else {
host = Services.io.newURI(this._view.selectedNode.uri).host;
}
let baseDomain;
try {
baseDomain = Services.eTLD.getBaseDomainFromHost(host);
} catch (e) {
// If there is no baseDomain we fall back to host
}
const [title, body, forget] = await document.l10n.formatValues([
{ id: "places-forget-about-this-site-confirmation-title" },
{
id: "places-forget-about-this-site-confirmation-msg",
args: { hostOrBaseDomain: baseDomain ?? host },
},
{ id: "places-forget-about-this-site-forget" },
]);
const flags =
Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 +
Services.prompt.BUTTON_POS_1_DEFAULT;
let bag = await Services.prompt.asyncConfirmEx(
window.browsingContext,
Services.prompt.MODAL_TYPE_INTERNAL_WINDOW,
title,
body,
flags,
forget,
null,
null,
null,
false
);
if (bag.getProperty("buttonNumClicked") !== 0) {
return;
}
if (this.forgetSiteClearByBaseDomain) {
await this.ForgetAboutSite.removeDataFromBaseDomain(host);
} else {
await this.ForgetAboutSite.removeDataFromDomain(host);
}
},
showInFolder(aBookmarkGuid) {
// Open containing folder in left pane/sidebar bookmark tree
let documentUrl = document.documentURI.toLowerCase();
if (documentUrl.endsWith("browser.xhtml")) {
// We're in a menu or a panel.
window.SidebarController._show("viewBookmarksSidebar").then(() => {
let theSidebar = document.getElementById("sidebar");
theSidebar.contentDocument
.getElementById("bookmarks-view")
.selectItems([aBookmarkGuid]);
});
} else if (documentUrl.includes("sidebar")) {
// We're in the sidebar - clear the search box first
let searchBox = document.getElementById("search-box");
searchBox.value = "";
searchBox.doCommand();
// And go to the node
this._view.selectItems([aBookmarkGuid], true);
} else {
// We're in the bookmark library/manager
PlacesUtils.bookmarks
.fetch(aBookmarkGuid, null, { includePath: true })
.then(b => {
let containers = b.path.map(obj => {
return obj.guid;
});
// selectLeftPane looks for literal "AllBookmarks" as a "built-in"
containers.splice(0, 0, "AllBookmarks");
PlacesOrganizer.selectLeftPaneContainerByHierarchy(containers);
this._view.selectItems([aBookmarkGuid], false);
});
}
},
};
/**
* 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 = {
/**
* For views using DOM nodes like toolbars, menus and panels, this is the DOM
* element currently being dragged over. For other views not handling DOM
* nodes, like trees, it is a Places result node instead.
*/
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 {object} node
* The container node
* @returns {boolean} true if the user is dragging over a node within the hierarchy of
* the container, false otherwise.
*/
draggingOverChildNode: function PCDH_draggingOverChildNode(node) {
let currentNode = this.currentDropTarget;
while (currentNode) {
if (currentNode == node) {
return true;
}
currentNode = currentNode.parentNode;
}
return false;
},
/**
* @returns {object|null} The current active drag session for the window.
* Returns null if there is none.
*/
getSession: function PCDH__getSession() {
return this.dragService.getCurrentSession(window);
},
/**
* Extract the most relevant flavor from a list of flavors.
*
* @param {DOMStringList} flavors The flavors list.
* @returns {string} The most relevant flavor, or undefined.
*/
getMostRelevantFlavor(flavors) {
// The DnD API returns a DOMStringList, but tests may pass an Array.
flavors = Array.from(flavors);
return PlacesUIUtils.SUPPORTED_FLAVORS.find(f => flavors.includes(f));
},
/**
* Determines whether or not the data currently being dragged can be dropped
* on a places view.
*
* @param {object} ip
* The insertion point where the items should be dropped.
* @param {object} dt
* The data transfer object.
* @returns {boolean}
*/
canDrop: function PCDH_canDrop(ip, dt) {
let dropCount = dt.mozItemCount;
// Check every dragged item.
for (let i = 0; i < dropCount; i++) {
let flavor = this.getMostRelevantFlavor(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 nodes;
try {
nodes = PlacesUtils.unwrapNodes(data, flavor);
} catch (e) {
return false;
}
for (let dragged of nodes) {
// Only bookmarks and urls can be dropped into tag containers.
if (
ip.isTag &&
dragged.type != PlacesUtils.TYPE_X_MOZ_URL &&
(dragged.type != PlacesUtils.TYPE_X_MOZ_PLACE ||
(dragged.uri && dragged.uri.startsWith("place:")))
) {
return false;
}
// Disallow dropping of a folder on itself or any of its descendants.
// This check is done to show an appropriate drop indicator, a stricter
// check is done later by the bookmarks API.
if (
dragged.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER ||
(dragged.uri && dragged.uri.startsWith("place:"))
) {
let dragOverPlacesNode = this.currentDropTarget;
if (!(dragOverPlacesNode instanceof Ci.nsINavHistoryResultNode)) {
// If it's a DOM node, it should have a _placesNode expando, or it
// may be a static element in a places container, like the [empty]
// menuitem.
dragOverPlacesNode =
dragOverPlacesNode._placesNode ??
dragOverPlacesNode.parentNode?._placesNode;
}
// If we couldn't get a target Places result node then we can't check
// whether the drag is allowed, just let it go through.
if (dragOverPlacesNode) {
let guid = dragged.concreteGuid ?? dragged.itemGuid;
// Dragging over itself.
if (PlacesUtils.getConcreteItemGuid(dragOverPlacesNode) == guid) {
return false;
}
// Dragging over a descendant.
for (let ancestor of PlacesUtils.nodeAncestors(
dragOverPlacesNode
)) {
if (PlacesUtils.getConcreteItemGuid(ancestor) == guid) {
return false;
}
}
}
}
// Disallow the dropping of multiple bookmarks if they include
// a javascript: bookmarklet
if (
!flavor.startsWith("text/x-moz-place") &&
(nodes.length > 1 || dropCount > 1) &&
nodes.some(n => n.uri?.startsWith("javascript:"))
) {
return false;
}
}
}
return true;
},
/**
* Handles the drop of one or more items onto a view.
*
* @param {object} insertionPoint The insertion point where the items should
* be dropped.
* @param {object} dt The dataTransfer information for the drop.
* @param {object} [view] The view or the tree element. This allows
* batching to take place.
*/
async onDrop(insertionPoint, dt, view) {
let doCopy = ["copy", "link"].includes(dt.dropEffect);
let dropCount = dt.mozItemCount;
// Following flavors may contain duplicated data.
let duplicable = new Map();
duplicable.set(PlacesUtils.TYPE_PLAINTEXT, new Set());
duplicable.set(PlacesUtils.TYPE_X_MOZ_URL, new Set());
// Collect all data from the DataTransfer before processing it, as the
// DataTransfer is only valid during the synchronous handling of the `drop`
// event handler callback.
let nodes = [];
let externalDrag = false;
for (let i = 0; i < dropCount; ++i) {
let flavor = this.getMostRelevantFlavor(dt.mozTypesAt(i));
if (!flavor) {
return;
}
let data = dt.mozGetDataAt(flavor, i);
if (duplicable.has(flavor)) {
let handled = duplicable.get(flavor);
if (handled.has(data)) {
continue;
}
handled.add(data);
}
// Check that the drag/drop is not internal
if (i == 0 && !flavor.startsWith("text/x-moz-place")) {
externalDrag = true;
}
if (flavor != TAB_DROP_TYPE) {
nodes = [...nodes, ...PlacesUtils.unwrapNodes(data, flavor)];
} else if (
XULElement.isInstance(data) &&
data.localName == "tab" &&
data.ownerGlobal.isChromeWindow
) {
let uri = data.linkedBrowser.currentURI;
let spec = uri ? uri.spec : "about:blank";
nodes.push({
uri: spec,
title: data.label,
type: PlacesUtils.TYPE_X_MOZ_URL,
});
} else {
throw new Error("bogus data was passed as a tab");
}
}
// If a multiple urls are being dropped from the urlbar or an external source,
// and they include javascript url, not bookmark any of them
if (
externalDrag &&
(nodes.length > 1 || dropCount > 1) &&
nodes.some(n => n.uri?.startsWith("javascript:"))
) {
throw new Error("Javascript bookmarklet passed with uris");
}
// If a single javascript url is being dropped from the urlbar or an external source,
// show the bookmark dialog as a speedbump protection against malicious cases.
if (
nodes.length == 1 &&
externalDrag &&
nodes[0].uri?.startsWith("javascript")
) {
let uri;
try {
uri = Services.io.newURI(nodes[0].uri);
} catch (ex) {
// Invalid uri, we skip this code and the entry will be discarded later.
}
if (uri) {
let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog(
{
action: "add",
type: "bookmark",
defaultInsertionPoint: insertionPoint,
hiddenRows: ["folderPicker"],
title: nodes[0].title,
uri,
},
BrowserWindowTracker.getTopWindow() // `window` may be the Library.
);
if (bookmarkGuid && view) {
view.selectItems([bookmarkGuid], false);
}
return;
}
}
await PlacesUIUtils.handleTransferItems(
nodes,
insertionPoint,
doCopy,
view
);
},
};
XPCOMUtils.defineLazyServiceGetter(
PlacesControllerDragHelper,
"dragService",
"@mozilla.org/widget/dragservice;1",
"nsIDragService"
);