Files
tubestation/waterfox/browser/components/sidebar/background/tab-context-menu.js
2025-11-06 14:13:52 +00:00

1861 lines
65 KiB
JavaScript

/*
# 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/.
*/
'use strict';
import EventListenerManager from '/extlib/EventListenerManager.js';
import {
log as internalLogger,
mapAndFilter,
configs,
sanitizeForHTMLText
} from '/common/common.js';
import * as ApiTabs from '/common/api-tabs.js';
import * as Constants from '/common/constants.js';
import * as ContextualIdentities from '/common/contextual-identities.js';
import * as SidebarConnection from '/common/sidebar-connection.js';
import * as Sync from '/common/sync.js';
import * as TabsInternalOperation from '/common/tabs-internal-operation.js';
import * as TabsStore from '/common/tabs-store.js';
import * as TreeBehavior from '/common/tree-behavior.js';
import * as TSTAPI from '/common/tst-api.js';
import { Tab, TreeItem } from '/common/TreeItem.js';
import * as Commands from './commands.js';
import * as NativeTabGroups from './native-tab-groups.js';
import * as TabsOpen from './tabs-open.js';
function log(...args) {
internalLogger('background/tab-context-menu', ...args);
}
export const onTSTItemClick = new EventListenerManager();
export const onTSTTabContextMenuShown = new EventListenerManager();
export const onTSTTabContextMenuHidden = new EventListenerManager();
export const onTopLevelItemAdded = new EventListenerManager();
const EXTERNAL_TOP_LEVEL_ITEM_MATCHER = /^external-top-level-item:([^:]+):(.+)$/;
function getExternalTopLevelItemId(ownerId, itemId) {
return `external-top-level-item:${ownerId}:${itemId}`;
}
const SAFE_MENU_PROPERTIES = [
'checked',
'enabled',
'icons',
'parentId',
'title',
'type',
'visible'
];
const mItemsById = {
'context_newTab': {
title: browser.i18n.getMessage('tabContextMenu_newTab_label'),
titleTab: browser.i18n.getMessage('tabContextMenu_newTabNext_label'),
},
'context_newGroup': {
title: browser.i18n.getMessage('tabContextMenu_newGroup_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_newGroup_label_multiselected')
},
'context_addToGroup': {
title: browser.i18n.getMessage('tabContextMenu_addToGroup_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_addToGroup_label_multiselected')
},
'context_addToGroup_newGroup': {
parentId: 'context_addToGroup',
title: browser.i18n.getMessage('tabContextMenu_addToGroup_newGroup_label'),
},
'context_addToGroup_separator:afterNewGroup': {
parentId: 'context_addToGroup',
type: 'separator',
},
'context_removeFromGroup': {
title: browser.i18n.getMessage('tabContextMenu_removeFromGroup_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_removeFromGroup_label_multiselected')
},
'context_separator:afterNewTab': {
type: 'separator'
},
'context_reloadTab': {
title: browser.i18n.getMessage('tabContextMenu_reload_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_reload_label_multiselected')
},
'context_topLevel_reloadTree': {
title: browser.i18n.getMessage('context_reloadTree_label'),
titleMultiselected: browser.i18n.getMessage('context_reloadTree_label_multiselected')
},
'context_topLevel_reloadDescendants': {
title: browser.i18n.getMessage('context_reloadDescendants_label'),
titleMultiselected: browser.i18n.getMessage('context_reloadDescendants_label_multiselected')
},
// This item won't be handled by the onClicked handler, so you may need to handle it with something experiments API.
'context_unblockAutoplay': {
title: browser.i18n.getMessage('tabContextMenu_unblockAutoplay_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_unblockAutoplay_label_multiselected')
},
// This item won't be handled by the onClicked handler, so you may need to handle it with something experiments API.
'context_topLevel_unblockAutoplayTree': {
title: browser.i18n.getMessage('context_unblockAutoplayTree_label'),
titleMultiselected: browser.i18n.getMessage('context_unblockAutoplayTree_label_multiselected'),
},
// This item won't be handled by the onClicked handler, so you may need to handle it with something experiments API.
'context_topLevel_unblockAutoplayDescendants': {
title: browser.i18n.getMessage('context_unblockAutoplayDescendants_label'),
titleMultiselected: browser.i18n.getMessage('context_unblockAutoplayDescendants_label_multiselected'),
},
'context_toggleMuteTab': {
titleMute: browser.i18n.getMessage('tabContextMenu_mute_label'),
titleUnmute: browser.i18n.getMessage('tabContextMenu_unmute_label'),
titleMultiselectedMute: browser.i18n.getMessage('tabContextMenu_mute_label_multiselected'),
titleMultiselectedUnmute: browser.i18n.getMessage('tabContextMenu_unmute_label_multiselected')
},
'context_topLevel_toggleMuteTree': {
titleMuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_mute'),
titleMultiselectedMuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_multiselected_mute'),
titleUnmuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_unmute'),
titleMultiselectedUnmuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_multiselected_unmute')
},
'context_topLevel_toggleMuteDescendants': {
titleMuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_mute'),
titleMultiselectedMuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_multiselected_mute'),
titleUnmuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_unmute'),
titleMultiselectedUnmuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_multiselected_unmute')
},
'context_pinTab': {
title: browser.i18n.getMessage('tabContextMenu_pin_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_pin_label_multiselected')
},
'context_unpinTab': {
title: browser.i18n.getMessage('tabContextMenu_unpin_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_unpin_label_multiselected')
},
'context_topLevel_toggleSticky': {
titleStick: browser.i18n.getMessage('context_toggleSticky_label_stick'),
titleMultiselectedStick: browser.i18n.getMessage('context_toggleSticky_label_multiselected_stick'),
titleUnstick: browser.i18n.getMessage('context_toggleSticky_label_unstick'),
titleMultiselectedUnstick: browser.i18n.getMessage('context_toggleSticky_label_multiselected_unstick')
},
'context_unloadTab': {
title: browser.i18n.getMessage('tabContextMenu_unload_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_unload_label_multiselected'),
},
'context_duplicateTab': {
title: browser.i18n.getMessage('tabContextMenu_duplicate_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_duplicate_label_multiselected')
},
'context_separator:afterDuplicate': {
type: 'separator'
},
'context_bookmarkTab': {
title: browser.i18n.getMessage('tabContextMenu_bookmark_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_bookmark_label_multiselected')
},
'context_topLevel_bookmarkTree': {
title: browser.i18n.getMessage('context_bookmarkTree_label'),
titleMultiselected: browser.i18n.getMessage('context_bookmarkTree_label_multiselected')
},
'context_moveTab': {
title: browser.i18n.getMessage('tabContextMenu_moveTab_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_moveTab_label_multiselected')
},
'context_moveTabToStart': {
parentId: 'context_moveTab',
title: browser.i18n.getMessage('tabContextMenu_moveTabToStart_label')
},
'context_moveTabToEnd': {
parentId: 'context_moveTab',
title: browser.i18n.getMessage('tabContextMenu_moveTabToEnd_label')
},
'context_openTabInWindow': {
parentId: 'context_moveTab',
title: browser.i18n.getMessage('tabContextMenu_tearOff_label')
},
'context_shareTabURL': {
title: browser.i18n.getMessage('tabContextMenu_shareTabURL_label'),
},
'context_sendTabsToDevice': {
title: browser.i18n.getMessage('tabContextMenu_sendTabsToDevice_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_sendTabsToDevice_label_multiselected')
},
'context_topLevel_sendTreeToDevice': {
title: browser.i18n.getMessage('context_sendTreeToDevice_label'),
titleMultiselected: browser.i18n.getMessage('context_sendTreeToDevice_label_multiselected')
},
'context_reopenInContainer': {
title: browser.i18n.getMessage('tabContextMenu_reopenInContainer_label')
},
'context_selectAllTabs': {
title: browser.i18n.getMessage('tabContextMenu_selectAllTabs_label')
},
'context_separator:afterSelectAllTabs': {
type: 'separator'
},
'context_topLevel_collapseTree': {
title: browser.i18n.getMessage('context_collapseTree_label'),
titleMultiselected: browser.i18n.getMessage('context_collapseTree_label_multiselected')
},
'context_topLevel_collapseTreeRecursively': {
title: browser.i18n.getMessage('context_collapseTreeRecursively_label'),
titleMultiselected: browser.i18n.getMessage('context_collapseTreeRecursively_label_multiselected')
},
'context_topLevel_collapseAll': {
title: browser.i18n.getMessage('context_collapseAll_label')
},
'context_topLevel_expandTree': {
title: browser.i18n.getMessage('context_expandTree_label'),
titleMultiselected: browser.i18n.getMessage('context_expandTree_label_multiselected')
},
'context_topLevel_expandTreeRecursively': {
title: browser.i18n.getMessage('context_expandTreeRecursively_label'),
titleMultiselected: browser.i18n.getMessage('context_expandTreeRecursively_label_multiselected')
},
'context_topLevel_expandAll': {
title: browser.i18n.getMessage('context_expandAll_label')
},
'context_separator:afterCollapseExpand': {
type: 'separator'
},
'context_closeTab': {
title: browser.i18n.getMessage('tabContextMenu_close_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_close_label_multiselected')
},
'context_closeDuplicatedTabs': {
title: browser.i18n.getMessage('tabContextMenu_closeDuplicatedTabs_label')
},
'context_closeMultipleTabs': {
title: browser.i18n.getMessage('tabContextMenu_closeMultipleTabs_label')
},
'context_closeTabsToTheStart': {
parentId: 'context_closeMultipleTabs',
title: browser.i18n.getMessage('tabContextMenu_closeTabsToTop_label')
},
'context_closeTabsToTheEnd': {
parentId: 'context_closeMultipleTabs',
title: browser.i18n.getMessage('tabContextMenu_closeTabsToBottom_label')
},
'context_closeOtherTabs': {
parentId: 'context_closeMultipleTabs',
title: browser.i18n.getMessage('tabContextMenu_closeOther_label')
},
'context_topLevel_closeTree': {
title: browser.i18n.getMessage('context_closeTree_label'),
titleMultiselected: browser.i18n.getMessage('context_closeTree_label_multiselected')
},
'context_topLevel_closeDescendants': {
title: browser.i18n.getMessage('context_closeDescendants_label'),
titleMultiselected: browser.i18n.getMessage('context_closeDescendants_label_multiselected')
},
'context_topLevel_closeOthers': {
title: browser.i18n.getMessage('context_closeOthers_label'),
titleMultiselected: browser.i18n.getMessage('context_closeOthers_label_multiselected')
},
'context_undoCloseTab': {
title: browser.i18n.getMessage('tabContextMenu_undoClose_label'),
titleRegular: browser.i18n.getMessage('tabContextMenu_undoClose_label'),
titleMultipleTabsRestorable: browser.i18n.getMessage('tabContextMenu_undoClose_label_multiple')
},
'context_separator:afterClose': {
type: 'separator'
},
'noContextTab:context_reloadTab': {
title: browser.i18n.getMessage('tabContextMenu_reloadSelected_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_reloadSelected_label_multiselected'),
},
'noContextTab:context_bookmarkSelected': {
title: browser.i18n.getMessage('tabContextMenu_bookmarkSelected_label'),
titleMultiselected: browser.i18n.getMessage('tabContextMenu_bookmarkSelected_label_multiselected'),
},
'noContextTab:context_selectAllTabs': {
title: browser.i18n.getMessage('tabContextMenu_selectAllTabs_label')
},
'noContextTab:context_undoCloseTab': {
title: browser.i18n.getMessage('tabContextMenu_undoClose_label')
},
'lastSeparatorBeforeExtraItems': {
type: 'separator',
fakeMenu: true
}
};
const mExtraItems = new Map();
const SIDEBAR_URL_PATTERN = [`${Constants.kSHORTHAND_URIS.tabbar}*`];
let mInitialized = false;
browser.runtime.onMessage.addListener(onMessage);
browser.menus.onShown.addListener(onShown);
browser.menus.onHidden.addListener(onHidden);
browser.menus.onClicked.addListener(onClick);
TSTAPI.onMessageExternal.addListener(onMessageExternal);
function getItemPlacementSignature(item) {
if (item.placementSignature)
return item.placementSignature;
return item.placementSignature = JSON.stringify({
parentId: item.parentId
});
}
export async function init() {
mInitialized = true;
window.addEventListener('unload', () => {
browser.runtime.onMessage.removeListener(onMessage);
TSTAPI.onMessageExternal.removeListener(onMessageExternal);
}, { once: true });
const itemIds = Object.keys(mItemsById);
for (const id of itemIds) {
const item = mItemsById[id];
item.id = id;
item.lastTitle = item.title;
item.lastVisible = false;
item.lastEnabled = true;
if (item.type == 'separator') {
let beforeSeparator = true;
item.precedingItems = [];
item.followingItems = [];
for (const id of itemIds) {
const possibleSibling = mItemsById[id];
if (getItemPlacementSignature(item) != getItemPlacementSignature(possibleSibling)) {
if (beforeSeparator)
continue;
else
break;
}
if (id == item.id) {
beforeSeparator = false;
continue;
}
if (beforeSeparator) {
if (possibleSibling.type == 'separator') {
item.previousSeparator = possibleSibling;
item.precedingItems = [];
}
else {
item.precedingItems.push(id);
}
}
else {
if (possibleSibling.type == 'separator')
break;
else
item.followingItems.push(id);
}
}
}
const info = {
id,
title: item.title,
type: item.type || 'normal',
contexts: ['tab'],
viewTypes: ['sidebar', 'tab', 'popup'],
visible: false, // hide all by default
documentUrlPatterns: SIDEBAR_URL_PATTERN
};
if (item.parentId)
info.parentId = item.parentId;
if (!item.fakeMenu)
browser.menus.create(info);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: info
}, browser.runtime);
}
onTSTItemClick.addListener(onClick);
await ContextualIdentities.init();
updateContextualIdentities();
ContextualIdentities.onUpdated.addListener(() => {
updateContextualIdentities();
});
}
// Workaround for https://github.com/piroor/treestyletab/issues/3423
// Firefox does not provide any API to access to the sharing service of the platform.
// We need to provide it as experiments API or something way.
// This module is designed to work with a service which has features:
// * async listServices(tab)
// - Returns an array of sharing services on macOS.
// - Retruned array should have 0 or more items like:
// { name: "service name",
// title: "title for a menu item",
// image: "icon image (maybe data: URI)" }
// * share(tab, shareName = null)
// - Returns nothing.
// - Shares the specified tab with the specified service.
// The second argument is optional because it is required only on macOS.
// * openPreferences()
// - Returns nothing.
// - Opens preferences of sharing services on macOS.
let mSharingService = null;
export function registerSharingService(service) {
mSharingService = service;
}
let mMultipleTabsRestorable = false;
Tab.onChangeMultipleTabsRestorability.addListener(multipleTabsRestorable => {
mMultipleTabsRestorable = multipleTabsRestorable;
});
const mNativeTabGroupItems = new Set();
function updateNativeTabGroups(contextTab) {
if (!contextTab ||
!configs.tabGroupsEnabled) {
return;
}
for (const item of mNativeTabGroupItems) {
const id = item.id;
if (id in mItemsById)
delete mItemsById[id];
browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor());
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_REMOVE,
params: id
}, browser.runtime);
}
mNativeTabGroupItems.clear();
updateItem('context_addToGroup_newGroup', {
visible: true,
});
updateItem('context_addToGroup_separator:afterNewGroup', {
visible: true,
});
const defaultTitle = browser.i18n.getMessage('tabContextMenu_addToGroup_unnamed_label');
const darkSuffix = window.matchMedia('(prefers-color-scheme: dark)').matches ? '-invert' : '';
const groups = getEffectiveTabGroups(contextTab.windowId);
for (const group of groups) {
if (contextTab.groupId == group.id) {
continue;
}
const id = `context_addToGroup:group:${group.id}`;
const item = {
id,
parentId: 'context_addToGroup',
title: group.title || defaultTitle,
icons: { 16: `/resources/icons/tab-group-chicklet.svg#${group.color}${darkSuffix}` },
contexts: ['tab'],
viewTypes: ['sidebar', 'tab', 'popup'],
documentUrlPatterns: SIDEBAR_URL_PATTERN,
};
browser.menus.create(item);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: item
}, browser.runtime);
mNativeTabGroupItems.add(item);
mItemsById[item.id] = item;
item.lastVisible = true;
item.lastEnabled = true;
}
}
function getEffectiveTabGroups(windowId) {
return TreeItem.sort(
[...TabsStore.windows.get(windowId).tabGroups.values()]
.filter(group => !!group.$TST.firstMember)
);
}
const mContextualIdentityItems = new Set();
function updateContextualIdentities() {
for (const item of mContextualIdentityItems) {
const id = item.id;
if (id in mItemsById)
delete mItemsById[id];
browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor());
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_REMOVE,
params: id
}, browser.runtime);
}
mContextualIdentityItems.clear();
const defaultItem = {
parentId: 'context_reopenInContainer',
id: 'context_reopenInContainer:firefox-default',
title: browser.i18n.getMessage('tabContextMenu_reopenInContainer_noContainer_label'),
contexts: ['tab'],
viewTypes: ['sidebar', 'tab', 'popup'],
documentUrlPatterns: SIDEBAR_URL_PATTERN
};
browser.menus.create(defaultItem);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: defaultItem
}, browser.runtime);
mContextualIdentityItems.add(defaultItem);
const defaultSeparator = {
parentId: 'context_reopenInContainer',
id: 'context_reopenInContainer_separator',
type: 'separator',
contexts: ['tab'],
viewTypes: ['sidebar', 'tab', 'popup'],
documentUrlPatterns: SIDEBAR_URL_PATTERN
};
browser.menus.create(defaultSeparator);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: defaultSeparator
}, browser.runtime);
mContextualIdentityItems.add(defaultSeparator);
ContextualIdentities.forEach(identity => {
const id = `context_reopenInContainer:${identity.cookieStoreId}`;
const item = {
parentId: 'context_reopenInContainer',
id: id,
title: identity.name.replace(/^([a-z0-9])/i, '&$1'),
contexts: ['tab'],
viewTypes: ['sidebar', 'tab', 'popup'],
documentUrlPatterns: SIDEBAR_URL_PATTERN
};
if (identity.iconUrl)
item.icons = { 16: identity.iconUrl };
browser.menus.create(item);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: item
}, browser.runtime);
mContextualIdentityItems.add(item);
mItemsById[item.id] = item;
item.lastVisible = true;
item.lastEnabled = true;
});
}
const mLastDevicesSignature = new Map();
const mSendToDeviceItems = new Map();
export async function updateSendToDeviceItems(parentId, { manage } = {}) {
const devices = await Sync.getOtherDevices();
const signature = JSON.stringify(devices.map(device => ({ id: device.id, name: device.name })));
if (signature == mLastDevicesSignature.get(parentId))
return false;
mLastDevicesSignature.set(parentId, signature);
const items = mSendToDeviceItems.get(parentId) || new Set();
for (const item of items) {
const id = item.id;
browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor());
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_REMOVE,
params: id
}, browser.runtime);
}
items.clear();
const baseParams = {
parentId,
contexts: ['tab'],
viewTypes: ['sidebar', 'tab', 'popup'],
documentUrlPatterns: SIDEBAR_URL_PATTERN
};
if (devices.length > 0) {
for (const device of devices) {
const item = {
...baseParams,
type: 'normal',
id: `${parentId}:device:${device.id}`,
title: device.name
};
if (device.icon)
item.icons = {
'16': `/resources/icons/${sanitizeForHTMLText(device.icon)}.svg`
};
browser.menus.create(item);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: item
}, browser.runtime);
items.add(item);
}
const separator = {
...baseParams,
type: 'separator',
id: `${parentId}:separator`
};
browser.menus.create(separator);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: separator
}, browser.runtime);
items.add(separator);
const sendToAllItem = {
...baseParams,
type: 'normal',
id: `${parentId}:all`,
title: browser.i18n.getMessage('tabContextMenu_sendTabsToAllDevices_label')
};
browser.menus.create(sendToAllItem);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: sendToAllItem
}, browser.runtime);
items.add(sendToAllItem);
}
if (manage) {
const manageItem = {
...baseParams,
type: 'normal',
id: `${parentId}:manage`,
title: browser.i18n.getMessage('tabContextMenu_manageSyncDevices_label')
};
browser.menus.create(manageItem);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: manageItem
}, browser.runtime);
items.add(manageItem);
}
mSendToDeviceItems.set(parentId, items);
return true;
}
const mLastSharingServicesSignature = new Map();
const mShareItems = new Map();
async function updateSharingServiceItems(parentId, contextTab) {
if (!mSharingService ||
!contextTab)
return false;
const services = await mSharingService.listServices(contextTab);
const signature = JSON.stringify(services);
if (signature == mLastSharingServicesSignature.get(parentId))
return false;
mLastSharingServicesSignature.set(parentId, signature);
const items = mShareItems.get(parentId) || new Set();
for (const item of items) {
const id = item.id;
browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor());
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_REMOVE,
params: id
}, browser.runtime);
}
items.clear();
const baseParams = {
parentId,
contexts: ['tab'],
viewTypes: ['sidebar', 'tab', 'popup'],
documentUrlPatterns: SIDEBAR_URL_PATTERN
};
if (services.length > 0) {
for (const service of services) {
const item = {
...baseParams,
type: 'normal',
id: `${parentId}:service:${service.name}`,
title: service.title,
};
if (service.image)
item.icons = {
'16': service.image,
};
browser.menus.create(item);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: item,
}, browser.runtime);
items.add(item);
}
const separator = {
...baseParams,
type: 'separator',
id: `${parentId}:separator`,
};
browser.menus.create(separator);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: separator,
}, browser.runtime);
items.add(separator);
const moreItem = {
...baseParams,
type: 'normal',
id: `${parentId}:more`,
title: browser.i18n.getMessage('tabContextMenu_shareTabURL_more_label'),
icons: {
'16': '/resources/icons/more-horiz-16.svg',
},
};
browser.menus.create(moreItem);
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_CREATE,
params: moreItem,
}, browser.runtime);
items.add(moreItem);
}
mShareItems.set(parentId, items);
return true;
}
function updateItem(id, state = {}) {
let modified = false;
const item = mItemsById[id];
if (!item) {
return false;
}
const updateInfo = {
visible: 'visible' in state ? !!state.visible : true,
enabled: 'enabled' in state ? !!state.enabled : true
};
if ('checked' in state)
updateInfo.checked = state.checked;
const title = String(
(state.tab && state.titleTab) ||
state.title ||
(state.multiselected && item.titleMultiselected) ||
item.title
).replace(/%S/g, state.count || 0);
if (title) {
updateInfo.title = title;
modified = title != item.lastTitle;
item.lastTitle = updateInfo.title;
}
if (!modified)
modified = updateInfo.visible != item.lastVisible ||
updateInfo.enabled != item.lastEnabled;
item.lastVisible = updateInfo.visible;
item.lastEnabled = updateInfo.enabled;
if (!item.fakeMenu) {
browser.menus.update(id, updateInfo).catch(ApiTabs.createErrorSuppressor());
}
onMessageExternal({
type: TSTAPI.kCONTEXT_MENU_UPDATE,
params: [id, updateInfo]
}, browser.runtime);
return modified;
}
function updateSeparator(id, options = {}) {
const item = mItemsById[id];
const visible = (
(options.hasVisiblePreceding ||
hasVisiblePrecedingItem(item)) &&
(options.hasVisibleFollowing ||
item.followingItems.some(id => mItemsById[id].type != 'separator' && mItemsById[id].lastVisible))
);
return updateItem(id, { visible });
}
function hasVisiblePrecedingItem(separator) {
return (
separator.precedingItems.some(id => mItemsById[id].type != 'separator' && mItemsById[id].lastVisible) ||
(separator.previousSeparator &&
!separator.previousSeparator.lastVisible &&
hasVisiblePrecedingItem(separator.previousSeparator))
);
}
let mOverriddenContext = null;
let mLastContextTabId = null;
async function onShown(info, contextTab) {
if (!mInitialized)
return;
const contextTabId = contextTab?.id;
mLastContextTabId = contextTabId;
try {
contextTab = Tab.get(contextTabId);
const windowId = contextTab ? contextTab.windowId : (await browser.windows.getLastFocused({}).catch(ApiTabs.createErrorHandler())).id;
if (mLastContextTabId != contextTabId)
return; // Skip further operations if the menu was already reopened on a different context tab.
const previousTab = contextTab?.$TST.previousTab;
const previousSiblingTab = contextTab?.$TST.previousSiblingTab;
const nextTab = contextTab?.$TST.nextTab;
const nextSiblingTab = contextTab?.$TST.nextSiblingTab;
const hasDuplicatedTabs = Tab.hasDuplicatedTabs(windowId);
const hasMultipleTabs = Tab.hasMultipleTabs(windowId);
const hasMultipleNormalTabs = Tab.hasMultipleTabs(windowId, { normal: true });
const multiselected = contextTab?.$TST.multiselected;
const contextTabs = multiselected ?
Tab.getSelectedTabs(windowId) :
contextTab ?
[contextTab] :
[];
const hasChild = contextTab && contextTabs.some(tab => tab.$TST.hasChild);
const { hasUnmutedTab, hasUnmutedDescendant } = Commands.getUnmutedState(contextTabs);
const { hasAutoplayBlockedTab, hasAutoplayBlockedDescendant } = Commands.getAutoplayBlockedState(contextTabs);
const hasChoosableNativeTabGroup = contextTab && getEffectiveTabGroups(windowId).filter(group => group.id != contextTab.groupId).length > 0;
if (mOverriddenContext)
return onOverriddenMenuShown(info, contextTab, windowId);
let modifiedItemsCount = cleanupOverriddenMenu();
// ESLint reports "short circuit" error for following codes.
// https://eslint.org/docs/rules/no-unused-expressions#allowshortcircuit
// To allow those usages, I disable the rule temporarily.
/* eslint-disable no-unused-expressions */
const emulate = configs.emulateDefaultContextMenu;
updateItem('context_newTab', {
visible: emulate,
tab: !!contextTab,
}) && modifiedItemsCount++;
updateItem('context_newGroup', {
visible: emulate && configs.tabGroupsEnabled && !!contextTab && !hasChoosableNativeTabGroup,
multiselected
}) && modifiedItemsCount++;
updateItem('context_addToGroup', {
visible: emulate && configs.tabGroupsEnabled && !!contextTab && hasChoosableNativeTabGroup,
multiselected
}) && modifiedItemsCount++;
updateItem('context_removeFromGroup', {
visible: emulate && configs.tabGroupsEnabled && !!contextTab && contextTab.groupId != -1,
multiselected
}) && modifiedItemsCount++;
updateItem('context_separator:afterNewTab', {
visible: emulate,
}) && modifiedItemsCount++;
updateItem('context_reloadTab', {
visible: emulate && !!contextTab,
multiselected
}) && modifiedItemsCount++;
updateItem('context_topLevel_reloadTree', {
visible: emulate && !!contextTab && configs.context_topLevel_reloadTree,
multiselected
}) && modifiedItemsCount++;
updateItem('context_topLevel_reloadDescendants', {
visible: emulate && !!contextTab && configs.context_topLevel_reloadDescendants,
enabled: hasChild,
multiselected
}) && modifiedItemsCount++;
updateItem('context_unblockAutoplay', {
visible: emulate && contextTab?.$TST.autoplayBlocked,
multiselected,
title: contextTab && Commands.getMenuItemTitle(mItemsById.context_unblockAutoplay, {
multiselected,
}),
}) && modifiedItemsCount++;
updateItem('context_topLevel_unblockAutoplayTree', {
visible: emulate && hasChild && hasAutoplayBlockedTab && configs.context_topLevel_unblockAutoplayTree,
multiselected,
title: contextTab && Commands.getMenuItemTitle(mItemsById.context_topLevel_unblockAutoplayTree, {
multiselected,
}),
}) && modifiedItemsCount++;
updateItem('context_topLevel_unblockAutoplayDescendants', {
visible: emulate && hasChild && hasAutoplayBlockedDescendant && configs.context_topLevel_unblockAutoplayDescendants,
multiselected,
title: contextTab && Commands.getMenuItemTitle(mItemsById.context_topLevel_unblockAutoplayDescendants, {
multiselected,
}),
}) && modifiedItemsCount++;
updateItem('context_toggleMuteTab', {
visible: emulate && !!contextTab,
multiselected,
title: contextTab && Commands.getMenuItemTitle(mItemsById.context_toggleMuteTab, {
multiselected,
unmuted: (!contextTab.mutedInfo || !contextTab.mutedInfo.muted),
}),
}) && modifiedItemsCount++;
updateItem('context_topLevel_toggleMuteTree', {
visible: emulate && !!contextTab && configs.context_topLevel_toggleMuteTree,
enabled: hasChild,
multiselected,
title: Commands.getMenuItemTitle(mItemsById.context_topLevel_toggleMuteTree, { multiselected, hasUnmutedTab, hasUnmutedDescendant }),
hasUnmutedTab,
hasUnmutedDescendant,
}) && modifiedItemsCount++;
updateItem('context_topLevel_toggleMuteDescendants', {
visible: emulate && !!contextTab && configs.context_topLevel_toggleMuteDescendants,
enabled: hasChild,
multiselected,
title: Commands.getMenuItemTitle(mItemsById.context_topLevel_toggleMuteDescendants, { multiselected, hasUnmutedTab, hasUnmutedDescendant }),
hasUnmutedTab,
hasUnmutedDescendant,
}) && modifiedItemsCount++;
updateItem('context_pinTab', {
visible: emulate && !!contextTab && !contextTab.pinned,
multiselected
}) && modifiedItemsCount++;
updateItem('context_unpinTab', {
visible: emulate && !!contextTab?.pinned,
multiselected
}) && modifiedItemsCount++;
updateItem('context_topLevel_toggleSticky', {
visible: emulate && !!contextTab,
enabled: contextTab && !contextTab.pinned,
multiselected,
title: contextTab && Commands.getMenuItemTitle(mItemsById.context_topLevel_toggleSticky, {
multiselected,
sticky: contextTab?.$TST.sticky,
}),
}) && modifiedItemsCount++;
const unloadableCount = Commands.filterUnloadableTabs(contextTabs).length;
updateItem('context_unloadTab', {
visible: emulate && unloadableCount > 0,
multiselected: unloadableCount > 1,
count: unloadableCount,
}) && modifiedItemsCount++;
updateItem('context_duplicateTab', {
visible: emulate && !!contextTab,
multiselected
}) && modifiedItemsCount++;
updateItem('context_bookmarkTab', {
visible: emulate && !!contextTab,
multiselected: multiselected || !contextTab
}) && modifiedItemsCount++;
updateItem('context_topLevel_bookmarkTree', {
visible: emulate && !!contextTab && configs.context_topLevel_bookmarkTree,
multiselected
}) && modifiedItemsCount++;
updateItem('context_moveTab', {
visible: emulate && !!contextTab,
enabled: contextTab && hasMultipleTabs,
multiselected
}) && modifiedItemsCount++;
updateItem('context_moveTabToStart', {
enabled: emulate && !!contextTab && hasMultipleTabs && (previousSiblingTab || previousTab) && ((previousSiblingTab || previousTab).pinned == contextTab.pinned),
multiselected
}) && modifiedItemsCount++;
updateItem('context_moveTabToEnd', {
enabled: emulate && !!contextTab && hasMultipleTabs && (nextSiblingTab || nextTab) && ((nextSiblingTab || nextTab).pinned == contextTab.pinned),
multiselected
}) && modifiedItemsCount++;
updateItem('context_openTabInWindow', {
enabled: emulate && !!contextTab && hasMultipleTabs,
multiselected
}) && modifiedItemsCount++;
// Not implemented yet as a built-in. See also: https://github.com/piroor/treestyletab/issues/3423
updateItem('context_shareTabURL', {
visible: emulate && !!contextTab && mSharingService && Sync.isSendableTab(contextTab),
}) && modifiedItemsCount++;
updateItem('context_sendTabsToDevice', {
visible: emulate && !!contextTab && contextTabs.filter(Sync.isSendableTab).length > 0,
multiselected,
count: contextTabs.length
}) && modifiedItemsCount++;
updateItem('context_topLevel_sendTreeToDevice', {
visible: emulate && !!contextTab && contextTabs.filter(Sync.isSendableTab).length > 0 && configs.context_topLevel_sendTreeToDevice && hasChild,
enabled: hasChild,
multiselected
}) && modifiedItemsCount++;
let showContextualIdentities = false;
if (contextTab && !contextTab.incognito) {
for (const item of mContextualIdentityItems.values()) {
const id = item.id;
let visible;
if (!emulate)
visible = false;
else if (id == 'context_reopenInContainer_separator')
visible = !!contextTab && contextTab.cookieStoreId != 'firefox-default';
else
visible = !!contextTab && id != `context_reopenInContainer:${contextTab.cookieStoreId}`;
updateItem(id, { visible }) && modifiedItemsCount++;
if (visible)
showContextualIdentities = true;
}
}
updateItem('context_reopenInContainer', {
visible: emulate && !!contextTab && showContextualIdentities && !contextTab.incognito,
enabled: contextTabs.every(tab => TabsOpen.isOpenable(tab.url)),
multiselected
}) && modifiedItemsCount++;
updateItem('context_selectAllTabs', {
visible: emulate && !!contextTab,
enabled: contextTab && Tab.getSelectedTabs(windowId).length != Tab.getVisibleTabs(windowId).length,
multiselected
}) && modifiedItemsCount++;
updateItem('context_topLevel_collapseTree', {
visible: emulate && !!contextTab && configs.context_topLevel_collapseTree,
enabled: hasChild,
multiselected
}) && modifiedItemsCount++;
updateItem('context_topLevel_collapseTreeRecursively', {
visible: emulate && !!contextTab && configs.context_topLevel_collapseTreeRecursively,
enabled: hasChild,
multiselected
}) && modifiedItemsCount++;
updateItem('context_topLevel_collapseAll', {
visible: emulate && !multiselected && !!contextTab && configs.context_topLevel_collapseAll
}) && modifiedItemsCount++;
updateItem('context_topLevel_expandTree', {
visible: emulate && !!contextTab && configs.context_topLevel_expandTree,
enabled: hasChild,
multiselected
}) && modifiedItemsCount++;
updateItem('context_topLevel_expandTreeRecursively', {
visible: emulate && !!contextTab && configs.context_topLevel_expandTreeRecursively,
enabled: hasChild,
multiselected
}) && modifiedItemsCount++;
updateItem('context_topLevel_expandAll', {
visible: emulate && !multiselected && !!contextTab && configs.context_topLevel_expandAll
}) && modifiedItemsCount++;
updateItem('context_closeTab', {
visible: emulate && !!contextTab,
multiselected,
count: contextTabs.length
}) && modifiedItemsCount++;
updateItem('context_closeDuplicatedTabs', {
visible: emulate && !!contextTab,
enabled: hasDuplicatedTabs && !multiselected,
multiselected
}) && modifiedItemsCount++;
updateItem('context_closeMultipleTabs', {
visible: emulate && !!contextTab,
enabled: hasMultipleNormalTabs,
multiselected
}) && modifiedItemsCount++;
updateItem('context_closeTabsToTheStart', {
visible: emulate && !!contextTab,
enabled: nextTab,
multiselected
}) && modifiedItemsCount++;
updateItem('context_closeTabsToTheEnd', {
visible: emulate && !!contextTab,
enabled: nextTab,
multiselected
}) && modifiedItemsCount++;
updateItem('context_closeOtherTabs', {
visible: emulate && !!contextTab,
enabled: hasMultipleNormalTabs,
multiselected
}) && modifiedItemsCount++;
updateItem('context_topLevel_closeTree', {
visible: emulate && !!contextTab && configs.context_topLevel_closeTree,
multiselected
}) && modifiedItemsCount++;
updateItem('context_topLevel_closeDescendants', {
visible: emulate && !!contextTab && configs.context_topLevel_closeDescendants,
enabled: hasChild,
multiselected
}) && modifiedItemsCount++;
updateItem('context_topLevel_closeOthers', {
visible: emulate && !!contextTab && configs.context_topLevel_closeOthers,
multiselected
}) && modifiedItemsCount++;
const undoCloseTabLabel = mItemsById.context_undoCloseTab[configs.undoMultipleTabsClose && mMultipleTabsRestorable ? 'titleMultipleTabsRestorable' : 'titleRegular'];
updateItem('context_undoCloseTab', {
title: undoCloseTabLabel,
visible: emulate && !!contextTab,
multiselected
}) && modifiedItemsCount++;
updateItem('noContextTab:context_reloadTab', {
visible: emulate && !contextTab
}) && modifiedItemsCount++;
updateItem('noContextTab:context_bookmarkSelected', {
visible: emulate && !contextTab
}) && modifiedItemsCount++;
updateItem('noContextTab:context_selectAllTabs', {
visible: emulate && !contextTab,
enabled: !contextTab && Tab.getSelectedTabs(windowId).length != Tab.getVisibleTabs(windowId).length
}) && modifiedItemsCount++;
updateItem('noContextTab:context_undoCloseTab', {
title: undoCloseTabLabel,
visible: emulate && !contextTab
}) && modifiedItemsCount++;
updateSeparator('context_separator:afterDuplicate') && modifiedItemsCount++;
updateSeparator('context_separator:afterSelectAllTabs') && modifiedItemsCount++;
updateSeparator('context_separator:afterCollapseExpand') && modifiedItemsCount++;
updateSeparator('context_separator:afterClose') && modifiedItemsCount++;
const flattenExtraItems = Array.from(mExtraItems.values()).flat();
updateSeparator('lastSeparatorBeforeExtraItems', {
hasVisibleFollowing: contextTab && flattenExtraItems.some(item => !item.parentId && item.visible !== false)
}) && modifiedItemsCount++;
// these items should be updated at the last to reduce flicking of showing context menu
await Promise.all([
updateNativeTabGroups(contextTab),
updateSendToDeviceItems('context_sendTabsToDevice', { manage: true }),
mItemsById.context_topLevel_sendTreeToDevice.lastVisible && updateSendToDeviceItems('context_topLevel_sendTreeToDevice'),
modifiedItemsCount > 0 && browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()).then(_ => false),
updateSharingServiceItems('context_shareTabURL', contextTab),
]).then(results => {
modifiedItemsCount = 0;
for (const modified of results) {
if (modified)
modifiedItemsCount++;
}
});
if (mLastContextTabId != contextTabId)
return; // Skip further operations if the menu was already reopened on a different context tab.
/* eslint-enable no-unused-expressions */
if (modifiedItemsCount > 0)
browser.menus.refresh().catch(ApiTabs.createErrorSuppressor());
}
catch(error) {
console.error(error);
}
}
let mLastOverriddenContextOwner = null;
function onOverriddenMenuShown(info, contextTab, windowId) {
if (!mLastOverriddenContextOwner) {
for (const itemId of Object.keys(mItemsById)) {
if (!mItemsById[itemId].lastVisible)
continue;
mItemsById[itemId].lastVisible = false;
browser.menus.update(itemId, { visible: false });
}
mLastOverriddenContextOwner = mOverriddenContext.owner;
}
for (const item of (mExtraItems.get(mLastOverriddenContextOwner) || [])) {
if (item.$topLevel &&
item.lastVisible) {
browser.menus.update(
getExternalTopLevelItemId(mOverriddenContext.owner, item.id),
{ visible: true }
);
}
}
const cache = {};
const message = {
type: TSTAPI.kCONTEXT_MENU_SHOWN,
info: {
bookmarkId: info.bookmarkId || null,
button: info.button,
checked: info.checked,
contexts: contextTab ? ['tab'] : info.bookmarkId ? ['bookmark'] : [],
editable: false,
frameId: null,
frameUrl: null,
linkText: null,
linkUrl: null,
mediaType: null,
menuIds: [],
menuItemId: null,
modifiers: [],
pageUrl: null,
parentMenuItemId: null,
selectionText: null,
srcUrl: null,
targetElementId: null,
viewType: 'sidebar',
wasChecked: false
},
tab: contextTab,
windowId
}
TSTAPI.broadcastMessage(message, {
targets: [mOverriddenContext.owner],
tabProperties: ['tab'],
isContextTab: true,
cache,
});
TSTAPI.broadcastMessage({
...message,
type: TSTAPI.kFAKE_CONTEXT_MENU_SHOWN
}, {
targets: [mOverriddenContext.owner],
tabProperties: ['tab']
});
reserveRefresh();
}
function cleanupOverriddenMenu() {
if (!mLastOverriddenContextOwner)
return 0;
let modifiedItemsCount = 0;
const owner = mLastOverriddenContextOwner;
mLastOverriddenContextOwner = null;
for (const itemId of Object.keys(mItemsById)) {
if (!mItemsById[itemId].lastVisible)
continue;
mItemsById[itemId].lastVisible = true;
browser.menus.update(itemId, { visible: true });
modifiedItemsCount++;
}
for (const item of (mExtraItems.get(owner) || [])) {
if (item.$topLevel &&
item.lastVisible) {
browser.menus.update(
getExternalTopLevelItemId(owner, item.id),
{ visible: false }
);
modifiedItemsCount++;
}
}
return modifiedItemsCount;
}
function onHidden() {
if (!mInitialized)
return;
const owner = mOverriddenContext?.owner;
const windowId = mOverriddenContext?.windowId;
if (mLastOverriddenContextOwner &&
owner == mLastOverriddenContextOwner) {
mOverriddenContext = null;
TSTAPI.broadcastMessage({
type: TSTAPI.kCONTEXT_MENU_HIDDEN,
windowId
}, {
targets: [owner]
});
TSTAPI.broadcastMessage({
type: TSTAPI.kFAKE_CONTEXT_MENU_HIDDEN,
windowId
}, {
targets: [owner]
});
}
}
async function onClick(info, contextTab) {
if (!mInitialized)
return;
contextTab = Tab.get(contextTab?.id);
const win = await browser.windows.getLastFocused({ populate: true }).catch(ApiTabs.createErrorHandler());
const windowId = contextTab?.windowId || win.id;
const activeTab = TabsStore.activeTabInWindow.get(windowId);
let multiselectedTabs = Tab.getSelectedTabs(windowId);
const isMultiselected = contextTab ? contextTab.$TST.multiselected : multiselectedTabs.length > 1;
if (!isMultiselected)
multiselectedTabs = null;
switch (info.menuItemId.replace(/^noContextTab:/, '')) {
case 'context_newTab': {
const behavior = info.button == 1 ?
configs.autoAttachOnNewTabButtonMiddleClick :
(info.modifiers && (info.modifiers.includes('Ctrl') || info.modifiers.includes('Command'))) ?
configs.autoAttachOnNewTabButtonAccelClick :
contextTab ?
configs.autoAttachOnContextNewTabCommand :
configs.autoAttachOnNewTabCommand;
Commands.openNewTabAs({
baseTab: contextTab || activeTab,
as: behavior,
});
}; break;
case 'context_newGroup':
case 'context_addToGroup_newGroup':
NativeTabGroups.addTabsToGroup(multiselectedTabs || [contextTab]).then(({ groupId, created }) => {
if (!created) {
return;
}
SidebarConnection.sendMessage({
type: Constants.kCOMMAND_SHOW_NATIVE_TAB_GROUP_MENU_PANEL,
windowId,
groupId,
});
});
break;
case 'context_removeFromGroup':
NativeTabGroups.removeTabsFromGroup(multiselectedTabs || [contextTab]);
break;
case 'context_reloadTab':
if (multiselectedTabs) {
for (const tab of multiselectedTabs) {
browser.tabs.reload(tab.id)
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
}
}
else {
const tab = contextTab || activeTab;
browser.tabs.reload(tab.id)
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
}
break;
case 'context_toggleMuteTab': {
const tab = contextTab || activeTab;
const unmuted = !tab.mutedInfo || !tab.mutedInfo.muted;
if (multiselectedTabs) {
for (const tab of multiselectedTabs) {
browser.tabs.update(tab.id, { muted: unmuted })
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
}
}
else {
browser.tabs.update(contextTab.id, { muted: unmuted })
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
}
}; break;
case 'context_pinTab':
if (multiselectedTabs) {
for (const tab of multiselectedTabs) {
browser.tabs.update(tab.id, { pinned: true })
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
}
}
else {
browser.tabs.update(contextTab.id, { pinned: true })
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
}
break;
case 'context_unpinTab':
if (multiselectedTabs) {
for (const tab of multiselectedTabs) {
browser.tabs.update(tab.id, { pinned: false })
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
}
}
else {
browser.tabs.update(contextTab.id, { pinned: false })
.catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));
}
break;
case 'context_toggleSticky':
Commands.toggleSticky(multiselectedTabs, !(contextTab || activeTab).$TST.sticky);
break;
case 'context_unloadTab':
Commands.unloadTabs(multiselectedTabs || [contextTab]);
break;
case 'context_duplicateTab':
Commands.duplicateTab(contextTab, {
destinationWindowId: windowId
});
break;
case 'context_moveTabToStart':
Commands.moveTabToStart(contextTab);
break;
case 'context_moveTabToEnd':
Commands.moveTabToEnd(contextTab);
break;
case 'context_openTabInWindow':
Commands.openTabInWindow(contextTab, { withTree: true });
break;
case 'context_shareTabURL':
if (mSharingService)
mSharingService.share(contextTab);
break;
case 'context_shareTabURL:more':
if (mSharingService)
mSharingService.openPreferences();
break;
case 'context_sendTabsToDevice:all':
Sync.sendTabsToAllDevices(multiselectedTabs || [contextTab]);
break;
case 'context_sendTabsToDevice:manage':
Sync.manageDevices(windowId);
break;
case 'context_topLevel_sendTreeToDevice:all':
Sync.sendTabsToAllDevices(multiselectedTabs || [contextTab], { recursively: true });
break;
case 'context_selectAllTabs': {
const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler());
TabsInternalOperation.highlightTabs(
[activeTab].concat(mapAndFilter(tabs, tab => !tab.active ? tab : undefined))
).catch(ApiTabs.createErrorSuppressor());
}; break;
case 'context_bookmarkTab':
Commands.bookmarkTab(contextTab);
break;
case 'context_bookmarkSelected':
Commands.bookmarkTab(contextTab || activeTab);
break;
case 'context_closeDuplicatedTabs': {
const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler());
tabs.sort((a, b) => b.lastAccessed - a.lastAccessed);
const tabKeys = new Set();
const closeTabs = [];
for (const tab of tabs) {
const key = `${tab.cookieStoreId}\n${tab.url}`;
if (tabKeys.has(key)) {
closeTabs.push(Tab.get(tab.id));
continue;
}
tabKeys.add(key);
}
const canceled = (await browser.runtime.sendMessage({
type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING,
tabs: closeTabs.map(tab => tab.$TST.sanitized),
windowId,
}).catch(ApiTabs.createErrorHandler())) === false;
if (canceled)
break;
TabsInternalOperation.removeTabs(closeTabs);
} break;
case 'context_closeTabsToTheStart': {
const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler());
const closeTabs = [];
const keptTabIds = new Set(
multiselectedTabs ?
multiselectedTabs.map(tab => tab.id) :
[contextTab.id]
);
for (const tab of tabs) {
if (keptTabIds.has(tab.id))
break;
if (!tab.pinned && !tab.hidden)
closeTabs.push(Tab.get(tab.id));
}
const canceled = (await browser.runtime.sendMessage({
type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING,
tabs: closeTabs.map(tab => tab.$TST.sanitized),
windowId
}).catch(ApiTabs.createErrorHandler())) === false;
if (canceled)
break;
TabsInternalOperation.removeTabs(closeTabs);
}; break;
case 'context_closeTabsToTheEnd': {
const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler());
tabs.reverse();
const closeTabs = [];
const keptTabIds = new Set(
(multiselectedTabs ?
multiselectedTabs :
[contextTab]
).reduce((tabIds, tab, _index) => {
if (tab.$TST.subtreeCollapsed)
tabIds.push(tab.id, ...tab.$TST.descendants.map(tab => tab.id))
else
tabIds.push(tab.id);
return tabIds;
}, [])
);
for (const tab of tabs) {
if (keptTabIds.has(tab.id))
break;
if (!tab.pinned && !tab.hidden)
closeTabs.push(Tab.get(tab.id));
}
closeTabs.reverse();
const canceled = (await browser.runtime.sendMessage({
type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING,
tabs: closeTabs.map(tab => tab.$TST.sanitized),
windowId
}).catch(ApiTabs.createErrorHandler())) === false;
if (canceled)
break;
TabsInternalOperation.removeTabs(closeTabs);
}; break;
case 'context_closeOtherTabs': {
const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler());
const keptTabIds = new Set(
(multiselectedTabs ?
multiselectedTabs :
[contextTab]
).reduce((tabIds, tab, _index) => {
if (tab.$TST.subtreeCollapsed)
tabIds.push(tab.id, ...tab.$TST.descendants.map(tab => tab.id))
else
tabIds.push(tab.id);
return tabIds;
}, [])
);
const closeTabs = mapAndFilter(tabs,
tab => !tab.pinned && !tab.hidden && !keptTabIds.has(tab.id) && Tab.get(tab.id) || undefined);
const canceled = (await browser.runtime.sendMessage({
type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING,
tabs: closeTabs.map(tab => tab.$TST.sanitized),
windowId
}).catch(ApiTabs.createErrorHandler())) === false;
if (canceled)
break;
TabsInternalOperation.removeTabs(closeTabs);
}; break;
case 'context_undoCloseTab': {
const sessions = await browser.sessions.getRecentlyClosed({ maxResults: 1 }).catch(ApiTabs.createErrorHandler());
if (sessions.length && sessions[0].tab)
browser.sessions.restore(sessions[0].tab.sessionId).catch(ApiTabs.createErrorSuppressor());
}; break;
case 'context_closeTab': {
const closeTabs = (multiselectedTabs || TreeBehavior.getClosingTabsFromParent(contextTab, {
byInternalOperation: true
})).reverse(); // close down to top, to keep tree structure of Tree Style Tab
const canceled = (await browser.runtime.sendMessage({
type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING,
tabs: closeTabs.map(tab => tab.$TST.sanitized),
windowId
}).catch(ApiTabs.createErrorHandler())) === false;
if (canceled)
return;
TabsInternalOperation.removeTabs(closeTabs);
}; break;
default: {
const nativeTabGroupMatch = info.menuItemId.match(/^context_addToGroup:group:(.+)$/);
if (contextTab &&
nativeTabGroupMatch)
NativeTabGroups.addTabsToGroup(multiselectedTabs || [contextTab], parseInt(nativeTabGroupMatch[1]));
const contextualIdentityMatch = info.menuItemId.match(/^context_reopenInContainer:(.+)$/);
if (contextTab &&
contextualIdentityMatch)
Commands.reopenInContainer(contextTab, contextualIdentityMatch[1]);
const shareTabsMatch = info.menuItemId.match(/^context_shareTabURL:service:(.+)$/);
if (mSharingService &&
contextTab &&
shareTabsMatch)
mSharingService.share(contextTab, shareTabsMatch[1]);
const sendTabsToDeviceMatch = info.menuItemId.match(/^context_sendTabsToDevice:device:(.+)$/);
if (contextTab &&
sendTabsToDeviceMatch)
Sync.sendTabsToDevice(
multiselectedTabs || [contextTab],
{ to: sendTabsToDeviceMatch[1] }
);
const sendTreeToDeviceMatch = info.menuItemId.match(/^context_topLevel_sendTreeToDevice:device:(.+)$/);
if (contextTab &&
sendTreeToDeviceMatch)
Sync.sendTabsToDevice(
multiselectedTabs || [contextTab],
{ to: sendTreeToDeviceMatch[1],
recursively: true }
);
if (EXTERNAL_TOP_LEVEL_ITEM_MATCHER.test(info.menuItemId)) {
const owner = RegExp.$1;
const menuItemId = RegExp.$2;
const message = {
type: TSTAPI.kCONTEXT_MENU_CLICK,
info: {
bookmarkId: info.bookmarkId || null,
button: info.button,
checked: info.checked,
editable: false,
frameId: null,
frameUrl: null,
linkText: null,
linkUrl: null,
mediaType: null,
menuItemId,
modifiers: [],
pageUrl: null,
parentMenuItemId: null,
selectionText: null,
srcUrl: null,
targetElementId: null,
viewType: 'sidebar',
wasChecked: info.wasChecked
},
tab: contextTab,
};
if (owner == browser.runtime.id) {
browser.runtime.sendMessage(message).catch(ApiTabs.createErrorSuppressor());
}
else {
const cache = {};
TSTAPI.sendMessage(
owner,
message,
{ tabProperties: ['tab'], cache, isContextTab: true }
).catch(ApiTabs.createErrorSuppressor());
TSTAPI.sendMessage(
owner,
{
...message,
type: TSTAPI.kFAKE_CONTEXT_MENU_CLICK
},
{ tabProperties: ['tab'], cache, isContextTab: true }
).catch(ApiTabs.createErrorSuppressor());
}
}
}; break;
}
}
function getItemsFor(addonId) {
if (mExtraItems.has(addonId)) {
return mExtraItems.get(addonId);
}
const items = [];
mExtraItems.set(addonId, items);
return items;
}
function exportExtraItems() {
const exported = {};
for (const [id, items] of mExtraItems.entries()) {
exported[id] = items;
}
return exported;
}
async function notifyUpdated() {
await browser.runtime.sendMessage({
type: Constants.kCOMMAND_NOTIFY_CONTEXT_MENU_UPDATED,
items: exportExtraItems()
}).catch(ApiTabs.createErrorSuppressor());
}
let mReservedNotifyUpdate;
let mNotifyUpdatedHandlers = [];
function reserveNotifyUpdated() {
return new Promise((resolve, _aReject) => {
mNotifyUpdatedHandlers.push(resolve);
if (mReservedNotifyUpdate)
clearTimeout(mReservedNotifyUpdate);
mReservedNotifyUpdate = setTimeout(async () => {
mReservedNotifyUpdate = undefined;
await notifyUpdated();
const handlers = mNotifyUpdatedHandlers;
mNotifyUpdatedHandlers = [];
for (const handler of handlers) {
handler();
}
}, 10);
});
}
function reserveRefresh() {
if (reserveRefresh.reserved)
clearTimeout(reserveRefresh.reserved);
reserveRefresh.reserved = setTimeout(() => {
reserveRefresh.reserved = null;;
browser.menus.refresh();
}, 0);
}
function onMessage(message, _sender) {
if (!mInitialized)
return;
log('tab-context-menu: internally called:', message);
switch (message.type) {
case Constants.kCOMMAND_GET_CONTEXT_MENU_ITEMS:
return Promise.resolve(exportExtraItems());
case TSTAPI.kCONTEXT_MENU_CLICK:
onTSTItemClick.dispatch(message.info, message.tab);
return;
case TSTAPI.kCONTEXT_MENU_SHOWN:
onShown(message.info, message.tab);
onTSTTabContextMenuShown.dispatch(message.info, message.tab);
return;
case TSTAPI.kCONTEXT_MENU_HIDDEN:
onTSTTabContextMenuHidden.dispatch();
return;
case Constants.kCOMMAND_NOTIFY_CONTEXT_ITEM_CHECKED_STATUS_CHANGED:
for (const itemData of mExtraItems.get(message.ownerId)) {
if (!itemData.id != message.id)
continue;
itemData.checked = message.checked;
break;
}
return;
case Constants.kCOMMAND_NOTIFY_CONTEXT_OVERRIDDEN:
mOverriddenContext = message.context || null;
if (mOverriddenContext) {
mOverriddenContext.owner = message.owner;
mOverriddenContext.windowId = message.windowId;
}
break;
// For optimization we update the context menu before the menu is actually opened if possible, to reduce visual flickings.
// But this optimization does not work as expected on environments which shows the context menu with mousedown, like macOS.
case TSTAPI.kNOTIFY_TAB_MOUSEDOWN:
if (message.button == 2) {
onShown(
message,
message.tab,
);
onTSTTabContextMenuShown.dispatch(message, message.tab);
}
break;
}
}
export function onMessageExternal(message, sender) {
if (!mInitialized)
return;
switch (message.type) {
case TSTAPI.kCONTEXT_MENU_CREATE:
case TSTAPI.kFAKE_CONTEXT_MENU_CREATE: {
log('TSTAPI.kCONTEXT_MENU_CREATE:', message, { id: sender.id, url: sender.url });
const items = getItemsFor(sender.id);
let params = message.params;
if (Array.isArray(params))
params = params[0];
const parent = params.parentId && items.filter(item => item.id == params.parentId)[0];
if (params.parentId && !parent)
break;
let shouldAdd = true;
if (params.id) {
for (let i = 0, maxi = items.length; i < maxi; i++) {
const item = items[i];
if (item.id != params.id)
continue;
items.splice(i, 1, params);
shouldAdd = false;
break;
}
}
if (shouldAdd) {
items.push(params);
if (parent?.id) {
parent.children = parent.children || [];
parent.children.push(params.id);
}
}
mExtraItems.set(sender.id, items);
params.$topLevel = (
Array.isArray(params.viewTypes) &&
params.viewTypes.includes('sidebar')
);
if (sender.id != browser.runtime.id &&
params.$topLevel) {
params.lastVisible = params.visible !== false;
const visible = !!(
params.lastVisible &&
mOverriddenContext &&
mLastOverriddenContextOwner == sender.id
);
const createParams = {
id: getExternalTopLevelItemId(sender.id, params.id),
type: params.type || 'normal',
visible,
viewTypes: ['sidebar', 'tab', 'popup'],
contexts: (params.contexts || []).filter(context => context == 'tab' || context == 'bookmark'),
documentUrlPatterns: SIDEBAR_URL_PATTERN
};
if (params.parentId)
createParams.parentId = getExternalTopLevelItemId(sender.id, params.parentId);
for (const property of SAFE_MENU_PROPERTIES) {
if (property in params)
createParams[property] = params[property];
}
browser.menus.create(createParams);
reserveRefresh();
onTopLevelItemAdded.dispatch();
}
return reserveNotifyUpdated();
}; break;
case TSTAPI.kCONTEXT_MENU_UPDATE:
case TSTAPI.kFAKE_CONTEXT_MENU_UPDATE: {
log('TSTAPI.kCONTEXT_MENU_UPDATE:', message, { id: sender.id, url: sender.url });
const items = getItemsFor(sender.id);
for (let i = 0, maxi = items.length; i < maxi; i++) {
const item = items[i];
if (item.id != message.params[0])
continue;
const params = message.params[1];
const updateProperties = {};
for (const property of SAFE_MENU_PROPERTIES) {
if (property in params)
updateProperties[property] = params[property];
}
if (sender.id != browser.runtime.id &&
item.$topLevel) {
if ('visible' in updateProperties)
item.lastVisible = updateProperties.visible;
if (!mOverriddenContext ||
mLastOverriddenContextOwner != sender.id)
delete updateProperties.visible;
}
items.splice(i, 1, {
...item,
...updateProperties
});
if (sender.id != browser.runtime.id &&
item.$topLevel &&
Object.keys(updateProperties).length > 0) {
browser.menus.update(
getExternalTopLevelItemId(sender.id, item.id),
updateProperties
);
reserveRefresh()
}
break;
}
mExtraItems.set(sender.id, items);
return reserveNotifyUpdated();
}; break;
case TSTAPI.kCONTEXT_MENU_REMOVE:
case TSTAPI.kFAKE_CONTEXT_MENU_REMOVE: {
log('TSTAPI.kCONTEXT_MENU_REMOVE:', message, { id: sender.id, url: sender.url });
let items = getItemsFor(sender.id);
let id = message.params;
if (Array.isArray(id))
id = id[0];
const item = items.filter(item => item.id == id)[0];
if (!item)
break;
const parent = item.parentId && items.filter(item => item.id == item.parentId)[0];
items = items.filter(item => item.id != id);
mExtraItems.set(sender.id, items);
if (parent?.children)
parent.children = parent.children.filter(childId => childId != id);
if (item.children) {
for (const childId of item.children) {
onMessageExternal({ type: message.type, params: childId }, sender);
}
}
if (sender.id != browser.runtime.id &&
item.$topLevel) {
browser.menus.remove(getExternalTopLevelItemId(sender.id, item.id));
reserveRefresh();
}
return reserveNotifyUpdated();
}; break;
case TSTAPI.kCONTEXT_MENU_REMOVE_ALL:
case TSTAPI.kFAKE_CONTEXT_MENU_REMOVE_ALL:
case TSTAPI.kUNREGISTER_SELF: {
delete mExtraItems.delete(sender.id);
return reserveNotifyUpdated();
}; break;
}
}