📝 Added entire config directory for backup purposes
This commit is contained in:
parent
4b38e59bb6
commit
9b946e2d14
11091 changed files with 1440953 additions and 0 deletions
|
|
@ -0,0 +1,166 @@
|
|||
const TIME_DELTA = 500; // Milliseconds.
|
||||
|
||||
// TabRecency associates a logical timestamp with each tab id. These are used to provide an initial
|
||||
// recency-based ordering in the tabs vomnibar (which allows jumping quickly between recently-visited tabs).
|
||||
class TabRecency {
|
||||
|
||||
constructor() {
|
||||
this.timestamp = 1;
|
||||
this.current = -1;
|
||||
this.cache = {};
|
||||
this.lastVisited = null;
|
||||
this.lastVisitedTime = null;
|
||||
|
||||
chrome.tabs.onActivated.addListener(activeInfo => this.register(activeInfo.tabId));
|
||||
chrome.tabs.onRemoved.addListener(tabId => this.deregister(tabId));
|
||||
|
||||
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
|
||||
this.deregister(removedTabId);
|
||||
this.register(addedTabId);
|
||||
});
|
||||
|
||||
if (chrome.windows != null) {
|
||||
chrome.windows.onFocusChanged.addListener(wnd => {
|
||||
if (wnd !== chrome.windows.WINDOW_ID_NONE) {
|
||||
chrome.tabs.query({windowId: wnd, active: true}, tabs => {
|
||||
if (tabs[0])
|
||||
this.register(tabs[0].id);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
register(tabId) {
|
||||
const currentTime = new Date();
|
||||
// Register tabId if it has been visited for at least @timeDelta ms. Tabs which are visited only for a
|
||||
// very-short time (e.g. those passed through with `5J`) aren't registered as visited at all.
|
||||
if ((this.lastVisitedTime != null) && (TIME_DELTA <= (currentTime - this.lastVisitedTime))) {
|
||||
this.cache[this.lastVisited] = ++this.timestamp;
|
||||
}
|
||||
|
||||
this.current = (this.lastVisited = tabId);
|
||||
this.lastVisitedTime = currentTime;
|
||||
}
|
||||
|
||||
deregister(tabId) {
|
||||
if (tabId === this.lastVisited) {
|
||||
// Ensure we don't register this tab, since it's going away.
|
||||
this.lastVisited = (this.lastVisitedTime = null);
|
||||
}
|
||||
delete this.cache[tabId];
|
||||
}
|
||||
|
||||
// Recently-visited tabs get a higher score (except the current tab, which gets a low score).
|
||||
recencyScore(tabId) {
|
||||
if (!this.cache[tabId])
|
||||
this.cache[tabId] = 1;
|
||||
if (tabId === this.current)
|
||||
return 0.0;
|
||||
else
|
||||
return this.cache[tabId] / this.timestamp;
|
||||
}
|
||||
|
||||
// Returns a list of tab Ids sorted by recency, most recent tab first.
|
||||
getTabsByRecency() {
|
||||
const tabIds = Object.keys(this.cache || {});
|
||||
tabIds.sort((a,b) => this.cache[b] - this.cache[a]);
|
||||
return tabIds.map(tId => parseInt(tId));
|
||||
}
|
||||
}
|
||||
|
||||
var BgUtils = {
|
||||
tabRecency: new TabRecency(),
|
||||
|
||||
// Log messages to the extension's logging page, but only if that page is open.
|
||||
log: (function() {
|
||||
const loggingPageUrl = chrome.runtime.getURL("pages/logging.html");
|
||||
if (loggingPageUrl != null) { console.log(`Vimium logging URL:\n ${loggingPageUrl}`); } // Do not output URL for tests.
|
||||
// For development, it's sometimes useful to automatically launch the logging page on reload.
|
||||
if (localStorage.autoLaunchLoggingPage) { chrome.windows.create({url: loggingPageUrl, focused: false}); }
|
||||
return function(message, sender = null) {
|
||||
for (let viewWindow of chrome.extension.getViews({type: "tab"})) {
|
||||
if (viewWindow.location.pathname === "/pages/logging.html") {
|
||||
// Don't log messages from the logging page itself. We do this check late because most of the time
|
||||
// it's not needed.
|
||||
if ((sender != null ? sender.url : undefined) !== loggingPageUrl) {
|
||||
const date = new Date;
|
||||
let [hours, minutes, seconds, milliseconds] =
|
||||
[date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()];
|
||||
if (minutes < 10) { minutes = "0" + minutes; }
|
||||
if (seconds < 10) { seconds = "0" + seconds; }
|
||||
if (milliseconds < 10) { milliseconds = "00" + milliseconds; }
|
||||
if (milliseconds < 100) { milliseconds = "0" + milliseconds; }
|
||||
const dateString = `${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
const logElement = viewWindow.document.getElementById("log-text");
|
||||
logElement.value += `${dateString}: ${message}\n`;
|
||||
logElement.scrollTop = 2000000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
})(),
|
||||
|
||||
// Remove comments and leading/trailing whitespace from a list of lines, and merge lines where the last
|
||||
// character on the preceding line is "\".
|
||||
parseLines(text) {
|
||||
return text.replace(/\\\n/g, "")
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(line => (line.length > 0) && !(Array.from('#"').includes(line[0])));
|
||||
},
|
||||
|
||||
escapedEntities: {
|
||||
'"': ""s;",
|
||||
'&': "&",
|
||||
"'": "'",
|
||||
"<": "<",
|
||||
">": ">"
|
||||
},
|
||||
|
||||
escapeAttribute(string) {
|
||||
return string.replace(/["&'<>]/g, char => BgUtils.escapedEntities[char]);
|
||||
}
|
||||
};
|
||||
|
||||
// Utility for parsing and using the custom search-engine configuration. We re-use the previous parse if the
|
||||
// search-engine configuration is unchanged.
|
||||
const SearchEngines = {
|
||||
previousSearchEngines: null,
|
||||
searchEngines: null,
|
||||
|
||||
refresh(searchEngines) {
|
||||
if ((this.previousSearchEngines == null) || (searchEngines !== this.previousSearchEngines)) {
|
||||
this.previousSearchEngines = searchEngines;
|
||||
this.searchEngines = new AsyncDataFetcher(function(callback) {
|
||||
const engines = {};
|
||||
for (let line of BgUtils.parseLines(searchEngines)) {
|
||||
const tokens = line.split(/\s+/);
|
||||
if (2 <= tokens.length) {
|
||||
const keyword = tokens[0].split(":")[0];
|
||||
const searchUrl = tokens[1];
|
||||
const description = tokens.slice(2).join(" ") || `search (${keyword})`;
|
||||
if (Utils.hasFullUrlPrefix(searchUrl) || Utils.hasJavascriptPrefix(searchUrl))
|
||||
engines[keyword] = {keyword, searchUrl, description};
|
||||
}
|
||||
}
|
||||
|
||||
callback(engines);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Use the parsed search-engine configuration, possibly asynchronously.
|
||||
use(callback) { this.searchEngines.use(callback); },
|
||||
|
||||
// Both set (refresh) the search-engine configuration and use it at the same time.
|
||||
refreshAndUse(searchEngines, callback) {
|
||||
this.refresh(searchEngines);
|
||||
this.use(callback);
|
||||
}
|
||||
};
|
||||
|
||||
BgUtils.TIME_DELTA = TIME_DELTA; // Referenced by our tests.
|
||||
|
||||
global.SearchEngines = SearchEngines;
|
||||
global.BgUtils = BgUtils;
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
const Commands = {
|
||||
availableCommands: {},
|
||||
keyToCommandRegistry: null,
|
||||
mapKeyRegistry: null,
|
||||
|
||||
init() {
|
||||
for (let command of Object.keys(commandDescriptions)) {
|
||||
const [description, options] = commandDescriptions[command];
|
||||
this.availableCommands[command] = Object.assign((options || {}), {description});
|
||||
}
|
||||
|
||||
Settings.postUpdateHooks["keyMappings"] = this.loadKeyMappings.bind(this);
|
||||
this.loadKeyMappings(Settings.get("keyMappings"));
|
||||
},
|
||||
|
||||
loadKeyMappings(customKeyMappings) {
|
||||
let key, command;
|
||||
this.keyToCommandRegistry = {};
|
||||
this.mapKeyRegistry = {};
|
||||
|
||||
const configLines = Object.keys(defaultKeyMappings).map((key) => `map ${key} ${defaultKeyMappings[key]}`);
|
||||
configLines.push(...BgUtils.parseLines(customKeyMappings));
|
||||
|
||||
const seen = {};
|
||||
let unmapAll = false;
|
||||
for (let line of configLines.reverse()) {
|
||||
const tokens = line.split(/\s+/);
|
||||
switch (tokens[0].toLowerCase()) {
|
||||
case "map":
|
||||
if ((3 <= tokens.length) && !unmapAll) {
|
||||
var _, optionList, registryEntry;
|
||||
[_, key, command, ...optionList] = tokens;
|
||||
if (!seen[key] && (registryEntry = this.availableCommands[command])) {
|
||||
seen[key] = true;
|
||||
const keySequence = this.parseKeySequence(key);
|
||||
const options = this.parseCommandOptions(command, optionList);
|
||||
this.keyToCommandRegistry[key] =
|
||||
Object.assign({keySequence, command, options, optionList}, this.availableCommands[command]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "unmap":
|
||||
if (tokens.length == 2)
|
||||
seen[tokens[1]] = true;
|
||||
break;
|
||||
case "unmapall":
|
||||
unmapAll = true;
|
||||
break;
|
||||
case "mapkey":
|
||||
if (tokens.length === 3) {
|
||||
const fromChar = this.parseKeySequence(tokens[1]);
|
||||
const toChar = this.parseKeySequence(tokens[2]);
|
||||
if ((fromChar.length === toChar.length && toChar.length === 1)
|
||||
&& this.mapKeyRegistry[fromChar[0]] == null) {
|
||||
this.mapKeyRegistry[fromChar[0]] = toChar[0];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
chrome.storage.local.set({mapKeyRegistry: this.mapKeyRegistry});
|
||||
this.installKeyStateMapping();
|
||||
this.prepareHelpPageData();
|
||||
|
||||
// Push the key mapping for passNextKey into Settings so that it's available in the front end for insert
|
||||
// mode. We exclude single-key mappings (that is, printable keys) because when users press printable keys
|
||||
// in insert mode they expect the character to be input, not to be droppped into some special Vimium
|
||||
// mode.
|
||||
const passNextKeys = Object.keys(this.keyToCommandRegistry)
|
||||
.filter(key => (this.keyToCommandRegistry[key].command === "passNextKey") && (key.length > 1));
|
||||
Settings.set("passNextKeyKeys", passNextKeys);
|
||||
},
|
||||
|
||||
// Lower-case the appropriate portions of named keys.
|
||||
//
|
||||
// A key name is one of three forms exemplified by <c-a> <left> or <c-f12>
|
||||
// (prefixed normal key, named key, or prefixed named key). Internally, for
|
||||
// simplicity, we would like prefixes and key names to be lowercase, though
|
||||
// humans may prefer other forms <Left> or <C-a>.
|
||||
// On the other hand, <c-a> and <c-A> are different named keys - for one of
|
||||
// them you have to press "shift" as well.
|
||||
// We sort modifiers here to match the order used in keyboard_utils.js.
|
||||
// The return value is a sequence of keys: e.g. "<Space><c-A>b" -> ["<space>", "<c-A>", "b"].
|
||||
parseKeySequence: (function() {
|
||||
const modifier = "(?:[acms]-)"; // E.g. "a-", "c-", "m-", "s-".
|
||||
const namedKey = "(?:[a-z][a-z0-9]+)"; // E.g. "left" or "f12" (always two characters or more).
|
||||
const modifiedKey = `(?:${modifier}+(?:.|${namedKey}))`; // E.g. "c-*" or "c-left".
|
||||
const specialKeyRegexp = new RegExp(`^<(${namedKey}|${modifiedKey})>(.*)`, "i");
|
||||
return function(key) {
|
||||
if (key.length === 0) {
|
||||
return [];
|
||||
// Parse "<c-a>bcd" as "<c-a>" and "bcd".
|
||||
} else if (0 === key.search(specialKeyRegexp)) {
|
||||
const array = RegExp.$1.split("-");
|
||||
const adjustedLength = Math.max(array.length, 1)
|
||||
let modifiers = array.slice(0, adjustedLength - 1);
|
||||
let keyChar = array[adjustedLength - 1];
|
||||
if (keyChar.length !== 1)
|
||||
keyChar = keyChar.toLowerCase();
|
||||
modifiers = modifiers.map(m => m.toLowerCase());
|
||||
modifiers.sort();
|
||||
return ["<" + modifiers.concat([keyChar]).join("-") + ">", ...this.parseKeySequence(RegExp.$2)];
|
||||
} else {
|
||||
return [key[0], ...this.parseKeySequence(key.slice(1))];
|
||||
}
|
||||
};
|
||||
})(),
|
||||
|
||||
// Command options follow command mappings, and are of one of two forms:
|
||||
// key=value - a value
|
||||
// key - a flag
|
||||
parseCommandOptions(command, optionList) {
|
||||
const options = {};
|
||||
for (let option of Array.from(optionList)) {
|
||||
const parse = option.split("=", 2);
|
||||
options[parse[0]] = parse.length === 1 ? true : parse[1];
|
||||
}
|
||||
|
||||
// We parse any `count` option immediately (to avoid having to parse it repeatedly later).
|
||||
if ("count" in options) {
|
||||
options.count = parseInt(options.count);
|
||||
if (isNaN(options.count) || this.availableCommands[command].noRepeat)
|
||||
delete options.count;
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
|
||||
// This generates and installs a nested key-to-command mapping structure. There is an example in
|
||||
// mode_key_handler.js.
|
||||
installKeyStateMapping() {
|
||||
const keyStateMapping = {};
|
||||
for (let keys of Object.keys(this.keyToCommandRegistry || {})) {
|
||||
const registryEntry = this.keyToCommandRegistry[keys];
|
||||
let currentMapping = keyStateMapping;
|
||||
for (let index = 0; index < registryEntry.keySequence.length; index++) {
|
||||
const key = registryEntry.keySequence[index];
|
||||
if (currentMapping[key] != null ? currentMapping[key].command : undefined) {
|
||||
// Do not overwrite existing command bindings, they take priority. NOTE(smblott) This is the legacy
|
||||
// behaviour.
|
||||
break;
|
||||
} else if (index < (registryEntry.keySequence.length - 1)) {
|
||||
currentMapping = currentMapping[key] != null ? currentMapping[key] : (currentMapping[key] = {});
|
||||
} else {
|
||||
currentMapping[key] = Object.assign({}, registryEntry);
|
||||
// We don't need these properties in the content scripts.
|
||||
for (let prop of ["keySequence", "description"])
|
||||
delete currentMapping[key][prop];
|
||||
}
|
||||
}
|
||||
}
|
||||
chrome.storage.local.set({normalModeKeyStateMapping: keyStateMapping});
|
||||
// Inform `KeyboardUtils.isEscape()` whether `<c-[>` should be interpreted as `Escape` (which it is by
|
||||
// default).
|
||||
chrome.storage.local.set({useVimLikeEscape: !("<c-[>" in keyStateMapping)});
|
||||
},
|
||||
|
||||
// Build the "helpPageData" data structure which the help page needs and place it in Chrome storage.
|
||||
prepareHelpPageData() {
|
||||
const commandToKey = {};
|
||||
for (let key of Object.keys(this.keyToCommandRegistry || {})) {
|
||||
const registryEntry = this.keyToCommandRegistry[key];
|
||||
(commandToKey[registryEntry.command] != null ? commandToKey[registryEntry.command] : (commandToKey[registryEntry.command] = [])).push(key);
|
||||
}
|
||||
const commandGroups = {};
|
||||
for (let group of Object.keys(this.commandGroups || {})) {
|
||||
const commands = this.commandGroups[group];
|
||||
commandGroups[group] = [];
|
||||
for (let command of commands) {
|
||||
commandGroups[group].push({
|
||||
command,
|
||||
description: this.availableCommands[command].description,
|
||||
keys: commandToKey[command] != null ? commandToKey[command] : [],
|
||||
advanced: this.advancedCommands.includes(command)
|
||||
});
|
||||
}
|
||||
}
|
||||
chrome.storage.local.set({helpPageData: commandGroups});
|
||||
},
|
||||
|
||||
// An ordered listing of all available commands, grouped by type. This is the order they will
|
||||
// be shown in the help page.
|
||||
commandGroups: {
|
||||
pageNavigation:
|
||||
["scrollDown",
|
||||
"scrollUp",
|
||||
"scrollToTop",
|
||||
"scrollToBottom",
|
||||
"scrollPageDown",
|
||||
"scrollPageUp",
|
||||
"scrollFullPageDown",
|
||||
"scrollFullPageUp",
|
||||
"scrollLeft",
|
||||
"scrollRight",
|
||||
"scrollToLeft",
|
||||
"scrollToRight",
|
||||
"reload",
|
||||
"copyCurrentUrl",
|
||||
"openCopiedUrlInCurrentTab",
|
||||
"openCopiedUrlInNewTab",
|
||||
"goUp",
|
||||
"goToRoot",
|
||||
"enterInsertMode",
|
||||
"enterVisualMode",
|
||||
"enterVisualLineMode",
|
||||
"passNextKey",
|
||||
"focusInput",
|
||||
"LinkHints.activateMode",
|
||||
"LinkHints.activateModeToOpenInNewTab",
|
||||
"LinkHints.activateModeToOpenInNewForegroundTab",
|
||||
"LinkHints.activateModeWithQueue",
|
||||
"LinkHints.activateModeToDownloadLink",
|
||||
"LinkHints.activateModeToOpenIncognito",
|
||||
"LinkHints.activateModeToCopyLinkUrl",
|
||||
"goPrevious",
|
||||
"goNext",
|
||||
"nextFrame",
|
||||
"mainFrame",
|
||||
"Marks.activateCreateMode",
|
||||
"Marks.activateGotoMode"],
|
||||
vomnibarCommands:
|
||||
["Vomnibar.activate",
|
||||
"Vomnibar.activateInNewTab",
|
||||
"Vomnibar.activateBookmarks",
|
||||
"Vomnibar.activateBookmarksInNewTab",
|
||||
"Vomnibar.activateTabSelection",
|
||||
"Vomnibar.activateEditUrl",
|
||||
"Vomnibar.activateEditUrlInNewTab"],
|
||||
findCommands: ["enterFindMode", "performFind", "performBackwardsFind"],
|
||||
historyNavigation:
|
||||
["goBack", "goForward"],
|
||||
tabManipulation:
|
||||
["createTab",
|
||||
"previousTab",
|
||||
"nextTab",
|
||||
"visitPreviousTab",
|
||||
"firstTab",
|
||||
"lastTab",
|
||||
"duplicateTab",
|
||||
"togglePinTab",
|
||||
"toggleMuteTab",
|
||||
"removeTab",
|
||||
"restoreTab",
|
||||
"moveTabToNewWindow",
|
||||
"closeTabsOnLeft","closeTabsOnRight",
|
||||
"closeOtherTabs",
|
||||
"moveTabLeft",
|
||||
"moveTabRight"],
|
||||
misc:
|
||||
["showHelp",
|
||||
"toggleViewSource"]
|
||||
},
|
||||
|
||||
// Rarely used commands are not shown by default in the help dialog or in the README. The goal is to present
|
||||
// a focused, high-signal set of commands to the new and casual user. Only those truly hungry for more power
|
||||
// from Vimium will uncover these gems.
|
||||
advancedCommands: [
|
||||
"scrollToLeft",
|
||||
"scrollToRight",
|
||||
"moveTabToNewWindow",
|
||||
"goUp",
|
||||
"goToRoot",
|
||||
"LinkHints.activateModeWithQueue",
|
||||
"LinkHints.activateModeToDownloadLink",
|
||||
"Vomnibar.activateEditUrl",
|
||||
"Vomnibar.activateEditUrlInNewTab",
|
||||
"LinkHints.activateModeToOpenIncognito",
|
||||
"LinkHints.activateModeToCopyLinkUrl",
|
||||
"goNext",
|
||||
"goPrevious",
|
||||
"Marks.activateCreateMode",
|
||||
"Marks.activateGotoMode",
|
||||
"moveTabLeft",
|
||||
"moveTabRight",
|
||||
"closeTabsOnLeft",
|
||||
"closeTabsOnRight",
|
||||
"closeOtherTabs",
|
||||
"enterVisualLineMode",
|
||||
"toggleViewSource",
|
||||
"passNextKey"]
|
||||
};
|
||||
|
||||
const defaultKeyMappings = {
|
||||
// Navigating the current page
|
||||
"j": "scrollDown",
|
||||
"k": "scrollUp",
|
||||
"h": "scrollLeft",
|
||||
"l": "scrollRight",
|
||||
"gg": "scrollToTop",
|
||||
"G": "scrollToBottom",
|
||||
"zH": "scrollToLeft",
|
||||
"zL": "scrollToRight",
|
||||
"<c-e>": "scrollDown",
|
||||
"<c-y>": "scrollUp",
|
||||
"d": "scrollPageDown",
|
||||
"u": "scrollPageUp",
|
||||
"r": "reload",
|
||||
"yy": "copyCurrentUrl",
|
||||
"p": "openCopiedUrlInCurrentTab",
|
||||
"P": "openCopiedUrlInNewTab",
|
||||
"gi": "focusInput",
|
||||
"[[": "goPrevious",
|
||||
"]]": "goNext",
|
||||
"gf": "nextFrame",
|
||||
"gF": "mainFrame",
|
||||
"gu": "goUp",
|
||||
"gU": "goToRoot",
|
||||
"i": "enterInsertMode",
|
||||
"v": "enterVisualMode",
|
||||
"V": "enterVisualLineMode",
|
||||
|
||||
// Link hints
|
||||
"f": "LinkHints.activateMode",
|
||||
"F": "LinkHints.activateModeToOpenInNewTab",
|
||||
"<a-f>": "LinkHints.activateModeWithQueue",
|
||||
"yf": "LinkHints.activateModeToCopyLinkUrl",
|
||||
|
||||
// Using find
|
||||
"/": "enterFindMode",
|
||||
"n": "performFind",
|
||||
"N": "performBackwardsFind",
|
||||
|
||||
// Vomnibar
|
||||
"o": "Vomnibar.activate",
|
||||
"O": "Vomnibar.activateInNewTab",
|
||||
"T": "Vomnibar.activateTabSelection",
|
||||
"b": "Vomnibar.activateBookmarks",
|
||||
"B": "Vomnibar.activateBookmarksInNewTab",
|
||||
"ge": "Vomnibar.activateEditUrl",
|
||||
"gE": "Vomnibar.activateEditUrlInNewTab",
|
||||
|
||||
// Navigating history
|
||||
"H": "goBack",
|
||||
"L": "goForward",
|
||||
|
||||
// Manipulating tabs
|
||||
"K": "nextTab",
|
||||
"J": "previousTab",
|
||||
"gt": "nextTab",
|
||||
"gT": "previousTab",
|
||||
"^": "visitPreviousTab",
|
||||
"<<": "moveTabLeft",
|
||||
">>": "moveTabRight",
|
||||
"g0": "firstTab",
|
||||
"g$": "lastTab",
|
||||
"W": "moveTabToNewWindow",
|
||||
"t": "createTab",
|
||||
"yt": "duplicateTab",
|
||||
"x": "removeTab",
|
||||
"X": "restoreTab",
|
||||
"<a-p>": "togglePinTab",
|
||||
"<a-m>": "toggleMuteTab",
|
||||
|
||||
// Marks
|
||||
"m": "Marks.activateCreateMode",
|
||||
"`": "Marks.activateGotoMode",
|
||||
|
||||
// Misc
|
||||
"?": "showHelp",
|
||||
"gs": "toggleViewSource"
|
||||
};
|
||||
|
||||
|
||||
// This is a mapping of: commandIdentifier => [description, options].
|
||||
// If the noRepeat and repeatLimit options are both specified, then noRepeat takes precedence.
|
||||
const commandDescriptions = {
|
||||
// Navigating the current page
|
||||
showHelp: ["Show help", { topFrame: true, noRepeat: true }],
|
||||
scrollDown: ["Scroll down"],
|
||||
scrollUp: ["Scroll up"],
|
||||
scrollLeft: ["Scroll left"],
|
||||
scrollRight: ["Scroll right"],
|
||||
|
||||
scrollToTop: ["Scroll to the top of the page"],
|
||||
scrollToBottom: ["Scroll to the bottom of the page", { noRepeat: true }],
|
||||
scrollToLeft: ["Scroll all the way to the left", { noRepeat: true }],
|
||||
scrollToRight: ["Scroll all the way to the right", { noRepeat: true }],
|
||||
|
||||
scrollPageDown: ["Scroll a half page down"],
|
||||
scrollPageUp: ["Scroll a half page up"],
|
||||
scrollFullPageDown: ["Scroll a full page down"],
|
||||
scrollFullPageUp: ["Scroll a full page up"],
|
||||
|
||||
reload: ["Reload the page", { background: true }],
|
||||
toggleViewSource: ["View page source", { noRepeat: true }],
|
||||
|
||||
copyCurrentUrl: ["Copy the current URL to the clipboard", { noRepeat: true }],
|
||||
openCopiedUrlInCurrentTab: ["Open the clipboard's URL in the current tab", { noRepeat: true }],
|
||||
openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { repeatLimit: 20 }],
|
||||
|
||||
enterInsertMode: ["Enter insert mode", { noRepeat: true }],
|
||||
passNextKey: ["Pass the next key to the page"],
|
||||
enterVisualMode: ["Enter visual mode", { noRepeat: true }],
|
||||
enterVisualLineMode: ["Enter visual line mode", { noRepeat: true }],
|
||||
|
||||
focusInput: ["Focus the first text input on the page"],
|
||||
|
||||
"LinkHints.activateMode": ["Open a link in the current tab"],
|
||||
"LinkHints.activateModeToOpenInNewTab": ["Open a link in a new tab"],
|
||||
"LinkHints.activateModeToOpenInNewForegroundTab": ["Open a link in a new tab & switch to it"],
|
||||
"LinkHints.activateModeWithQueue": ["Open multiple links in a new tab", { noRepeat: true }],
|
||||
"LinkHints.activateModeToOpenIncognito": ["Open a link in incognito window"],
|
||||
"LinkHints.activateModeToDownloadLink": ["Download link url"],
|
||||
"LinkHints.activateModeToCopyLinkUrl": ["Copy a link URL to the clipboard"],
|
||||
|
||||
enterFindMode: ["Enter find mode", { noRepeat: true }],
|
||||
performFind: ["Cycle forward to the next find match"],
|
||||
performBackwardsFind: ["Cycle backward to the previous find match"],
|
||||
|
||||
goPrevious: ["Follow the link labeled previous or <", { noRepeat: true }],
|
||||
goNext: ["Follow the link labeled next or >", { noRepeat: true }],
|
||||
|
||||
// Navigating your history
|
||||
goBack: ["Go back in history"],
|
||||
goForward: ["Go forward in history"],
|
||||
|
||||
// Navigating the URL hierarchy
|
||||
goUp: ["Go up the URL hierarchy"],
|
||||
goToRoot: ["Go to root of current URL hierarchy"],
|
||||
|
||||
// Manipulating tabs
|
||||
nextTab: ["Go one tab right", { background: true }],
|
||||
previousTab: ["Go one tab left", { background: true }],
|
||||
visitPreviousTab: ["Go to previously-visited tab", { background: true }],
|
||||
firstTab: ["Go to the first tab", { background: true }],
|
||||
lastTab: ["Go to the last tab", { background: true }],
|
||||
|
||||
createTab: ["Create new tab", { background: true, repeatLimit: 20 }],
|
||||
duplicateTab: ["Duplicate current tab", { background: true, repeatLimit: 20 }],
|
||||
removeTab: ["Close current tab", { background: true,
|
||||
repeatLimit: (chrome.session ? chrome.session.MAX_SESSION_RESULTS : null) || 25 }],
|
||||
restoreTab: ["Restore closed tab", { background: true, repeatLimit: 20 }],
|
||||
|
||||
moveTabToNewWindow: ["Move tab to new window", { background: true }],
|
||||
togglePinTab: ["Pin or unpin current tab", { background: true }],
|
||||
toggleMuteTab: ["Mute or unmute current tab", { background: true, noRepeat: true }],
|
||||
|
||||
closeTabsOnLeft: ["Close tabs on the left", {background: true, noRepeat: true}],
|
||||
closeTabsOnRight: ["Close tabs on the right", {background: true, noRepeat: true}],
|
||||
closeOtherTabs: ["Close all other tabs", {background: true, noRepeat: true}],
|
||||
|
||||
moveTabLeft: ["Move tab to the left", { background: true }],
|
||||
moveTabRight: ["Move tab to the right", { background: true }],
|
||||
|
||||
"Vomnibar.activate": ["Open URL, bookmark or history entry", { topFrame: true }],
|
||||
"Vomnibar.activateInNewTab": ["Open URL, bookmark or history entry in a new tab", { topFrame: true }],
|
||||
"Vomnibar.activateTabSelection": ["Search through your open tabs", { topFrame: true }],
|
||||
"Vomnibar.activateBookmarks": ["Open a bookmark", { topFrame: true }],
|
||||
"Vomnibar.activateBookmarksInNewTab": ["Open a bookmark in a new tab", { topFrame: true }],
|
||||
"Vomnibar.activateEditUrl": ["Edit the current URL", { topFrame: true }],
|
||||
"Vomnibar.activateEditUrlInNewTab": ["Edit the current URL and open in a new tab", { topFrame: true }],
|
||||
|
||||
nextFrame: ["Select the next frame on the page", { background: true }],
|
||||
mainFrame: ["Select the page's main/top frame", { topFrame: true, noRepeat: true }],
|
||||
|
||||
"Marks.activateCreateMode": ["Create a new mark", { noRepeat: true }],
|
||||
"Marks.activateGotoMode": ["Go to a mark", { noRepeat: true }]
|
||||
};
|
||||
|
||||
Commands.init();
|
||||
|
||||
global.Commands = Commands;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,258 @@
|
|||
// A completion engine provides search suggestions for a custom search engine. A custom search engine is
|
||||
// identified by a "searchUrl". An "engineUrl" is used for fetching suggestions, whereas a "searchUrl" is used
|
||||
// for the actual search itself.
|
||||
//
|
||||
// Each completion engine defines:
|
||||
//
|
||||
// 1. An "engineUrl". This is the URL to use for search completions and is passed as the option "engineUrl"
|
||||
// to the "BaseEngine" constructor.
|
||||
//
|
||||
// 2. One or more regular expressions which define the custom search engine URLs for which the completion
|
||||
// engine will be used. This is passed as the "regexps" option to the "BaseEngine" constructor.
|
||||
//
|
||||
// 3. A "parse" function. This takes a successful XMLHttpRequest object (the request has completed
|
||||
// successfully), and returns a list of suggestions (a list of strings). This method is always executed
|
||||
// within the context of a try/catch block, so errors do not propagate.
|
||||
//
|
||||
// 4. Each completion engine *must* include an example custom search engine. The example must include an
|
||||
// example "keyword" and an example "searchUrl", and may include an example "description" and an
|
||||
// "explanation".
|
||||
//
|
||||
// Each new completion engine must be added to the list "CompletionEngines" at the bottom of this file.
|
||||
//
|
||||
// The lookup logic which uses these completion engines is in "./completion_search.js".
|
||||
//
|
||||
|
||||
// A base class for common regexp-based matching engines. "options" must define:
|
||||
// options.engineUrl: the URL to use for the completion engine. This must be a string.
|
||||
// options.regexps: one or regular expressions. This may either a single string or a list of strings.
|
||||
// options.example: an example object containing at least "keyword" and "searchUrl", and optional "description".
|
||||
class BaseEngine {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.regexps = this.regexps.map(regexp => new RegExp(regexp));
|
||||
}
|
||||
|
||||
match(searchUrl) { return Utils.matchesAnyRegexp(this.regexps, searchUrl); }
|
||||
getUrl(queryTerms) { return Utils.createSearchUrl(queryTerms, this.engineUrl); }
|
||||
}
|
||||
|
||||
// Several Google completion engines package responses as XML. This parses such XML.
|
||||
class GoogleXMLBaseEngine extends BaseEngine {
|
||||
parse(xhr) {
|
||||
return Array.from(xhr.responseXML.getElementsByTagName("suggestion"))
|
||||
.map(suggestion => suggestion.getAttribute("data"))
|
||||
.filter(suggestion => suggestion);
|
||||
}
|
||||
}
|
||||
|
||||
class Google extends GoogleXMLBaseEngine {
|
||||
constructor() {
|
||||
super({
|
||||
engineUrl: "https://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=%s",
|
||||
regexps: ["^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/"],
|
||||
example: {
|
||||
searchUrl: "https://www.google.com/search?q=%s",
|
||||
keyword: "g"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class GoogleMaps extends GoogleXMLBaseEngine {
|
||||
constructor() {
|
||||
const q = GoogleMaps.prefix.split(" ").join("+");
|
||||
super({
|
||||
engineUrl: `https://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=${q}%s`,
|
||||
regexps: ["^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/maps"],
|
||||
example: {
|
||||
searchUrl: "https://www.google.com/maps?q=%s",
|
||||
keyword: "m",
|
||||
explanation:
|
||||
`\
|
||||
This uses regular Google completion, but prepends the text "<tt>map of</tt>" to the query. It works
|
||||
well for places, countries, states, geographical regions and the like, but will not perform address
|
||||
search.\
|
||||
`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
parse(xhr) {
|
||||
return Array.from(super.parse(xhr))
|
||||
.filter(suggestion => suggestion.startsWith(GoogleMaps.prefix))
|
||||
.map(suggestion => suggestion.slice(GoogleMaps.prefix.length));
|
||||
}
|
||||
}
|
||||
|
||||
GoogleMaps.prefix = "map of";
|
||||
|
||||
class Youtube extends GoogleXMLBaseEngine {
|
||||
constructor() {
|
||||
super({
|
||||
engineUrl: "https://suggestqueries.google.com/complete/search?client=youtube&ds=yt&xml=t&q=%s",
|
||||
regexps: ["^https?://[a-z]+\\.youtube\\.com/results"],
|
||||
example: {
|
||||
searchUrl: "https://www.youtube.com/results?search_query=%s",
|
||||
keyword: "y"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class Wikipedia extends BaseEngine {
|
||||
constructor() {
|
||||
super({
|
||||
engineUrl: "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=%s",
|
||||
regexps: ["^https?://[a-z]+\\.wikipedia\\.org/"],
|
||||
example: {
|
||||
searchUrl: "https://www.wikipedia.org/w/index.php?title=Special:Search&search=%s",
|
||||
keyword: "w"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
parse(xhr) { return JSON.parse(xhr.responseText)[1]; }
|
||||
}
|
||||
|
||||
class Bing extends BaseEngine {
|
||||
constructor() {
|
||||
super({
|
||||
engineUrl: "https://api.bing.com/osjson.aspx?query=%s",
|
||||
regexps: ["^https?://www\\.bing\\.com/search"],
|
||||
example: {
|
||||
searchUrl: "https://www.bing.com/search?q=%s",
|
||||
keyword: "b"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
parse(xhr) { return JSON.parse(xhr.responseText)[1]; }
|
||||
}
|
||||
|
||||
class Amazon extends BaseEngine {
|
||||
constructor() {
|
||||
super({
|
||||
engineUrl: "https://completion.amazon.com/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=1&q=%s",
|
||||
regexps: ["^https?://www\\.amazon\\.(com|co\\.uk|ca|de|com\\.au)/s/"],
|
||||
example: {
|
||||
searchUrl: "https://www.amazon.com/s/?field-keywords=%s",
|
||||
keyword: "a"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
parse(xhr) { return JSON.parse(xhr.responseText)[1]; }
|
||||
}
|
||||
|
||||
class AmazonJapan extends BaseEngine {
|
||||
constructor() {
|
||||
super({
|
||||
engineUrl: "https://completion.amazon.co.jp/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=6&q=%s",
|
||||
regexps: ["^https?://www\\.amazon\\.co\\.jp/(s/|gp/search)"],
|
||||
example: {
|
||||
searchUrl: "https://www.amazon.co.jp/s/?field-keywords=%s",
|
||||
keyword: "aj"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
parse(xhr) { return JSON.parse(xhr.responseText)[1]; }
|
||||
}
|
||||
|
||||
class DuckDuckGo extends BaseEngine {
|
||||
constructor() {
|
||||
super({
|
||||
engineUrl: "https://duckduckgo.com/ac/?q=%s",
|
||||
regexps: ["^https?://([a-z]+\\.)?duckduckgo\\.com/"],
|
||||
example: {
|
||||
searchUrl: "https://duckduckgo.com/?q=%s",
|
||||
keyword: "d"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
parse(xhr) {
|
||||
return Array.from(JSON.parse(xhr.responseText)).map((suggestion) => suggestion.phrase);
|
||||
}
|
||||
}
|
||||
|
||||
class Webster extends BaseEngine {
|
||||
constructor() {
|
||||
super({
|
||||
engineUrl: "https://www.merriam-webster.com/lapi/v1/mwol-search/autocomplete?search=%s",
|
||||
regexps: ["^https?://www.merriam-webster.com/dictionary/"],
|
||||
example: {
|
||||
searchUrl: "https://www.merriam-webster.com/dictionary/%s",
|
||||
keyword: "dw",
|
||||
description: "Dictionary"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
parse(xhr) {
|
||||
return Array.from(JSON.parse(xhr.responseText).docs).map((suggestion) => suggestion.word);
|
||||
}
|
||||
}
|
||||
|
||||
class Qwant extends BaseEngine {
|
||||
constructor() {
|
||||
super({
|
||||
engineUrl: "https://api.qwant.com/api/suggest?q=%s",
|
||||
regexps: ["^https?://www\\.qwant\\.com/"],
|
||||
example: {
|
||||
searchUrl: "https://www.qwant.com/?q=%s",
|
||||
keyword: "qw"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
parse(xhr) {
|
||||
return Array.from(JSON.parse(xhr.responseText).data.items).map((suggestion) => suggestion.value);
|
||||
}
|
||||
}
|
||||
|
||||
class UpToDate extends BaseEngine {
|
||||
constructor() {
|
||||
super({
|
||||
engineUrl: "https://www.uptodate.com/services/app/contents/search/autocomplete/json?term=%s&limit=10",
|
||||
regexps: ["^https?://www\\.uptodate\\.com/"],
|
||||
example: {
|
||||
searchUrl: "https://www.uptodate.com/contents/search?search=%s&searchType=PLAIN_TEXT&source=USER_INPUT&searchControl=TOP_PULLDOWN&autoComplete=false",
|
||||
keyword: "upto"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
parse(xhr) { return JSON.parse(xhr.responseText).data.searchTerms; }
|
||||
}
|
||||
|
||||
// A dummy search engine which is guaranteed to match any search URL, but never produces completions. This
|
||||
// allows the rest of the logic to be written knowing that there will always be a completion engine match.
|
||||
class DummyCompletionEngine extends BaseEngine {
|
||||
constructor() {
|
||||
super({
|
||||
regexps: ["."],
|
||||
dummy: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Order matters here.
|
||||
const CompletionEngines = [
|
||||
Youtube,
|
||||
GoogleMaps,
|
||||
Google,
|
||||
DuckDuckGo,
|
||||
Wikipedia,
|
||||
Bing,
|
||||
Amazon,
|
||||
AmazonJapan,
|
||||
Webster,
|
||||
Qwant,
|
||||
UpToDate,
|
||||
DummyCompletionEngine
|
||||
];
|
||||
|
||||
global.CompletionEngines = CompletionEngines;
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
// This is a wrapper class for completion engines. It handles the case where a custom search engine includes a
|
||||
// prefix query term (or terms). For example:
|
||||
//
|
||||
// https://www.google.com/search?q=javascript+%s
|
||||
//
|
||||
// In this case, we get better suggestions if we include the term "javascript" in queries sent to the
|
||||
// completion engine. This wrapper handles adding such prefixes to completion-engine queries and removing them
|
||||
// from the resulting suggestions.
|
||||
class EnginePrefixWrapper {
|
||||
constructor(searchUrl, engine) {
|
||||
this.searchUrl = searchUrl;
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
getUrl(queryTerms) {
|
||||
// This tests whether @searchUrl contains something of the form "...=abc+def+%s...", from which we extract
|
||||
// a prefix of the form "abc def ".
|
||||
if (/\=.+\+%s/.test(this.searchUrl)) {
|
||||
let terms = this.searchUrl.replace(/\+%s.*/, "");
|
||||
terms = terms.replace(/.*=/, "");
|
||||
terms = terms.replace(/\+/g, " ");
|
||||
|
||||
queryTerms = [ ...terms.split(" "), ...queryTerms ];
|
||||
const prefix = `${terms} `;
|
||||
|
||||
this.postprocessSuggestions = (suggestions) => {
|
||||
return suggestions
|
||||
.filter(s => s.startsWith(prefix))
|
||||
.map(s => s.slice(prefix.length));
|
||||
};
|
||||
}
|
||||
|
||||
return this.engine.getUrl(queryTerms);
|
||||
}
|
||||
|
||||
parse(xhr) {
|
||||
return this.postprocessSuggestions(this.engine.parse(xhr));
|
||||
}
|
||||
|
||||
postprocessSuggestions(suggestions) { return suggestions; }
|
||||
}
|
||||
|
||||
const CompletionSearch = {
|
||||
debug: false,
|
||||
inTransit: {},
|
||||
completionCache: new SimpleCache(2 * 60 * 60 * 1000, 5000), // Two hours, 5000 entries.
|
||||
engineCache:new SimpleCache(1000 * 60 * 60 * 1000), // 1000 hours.
|
||||
|
||||
// The amount of time to wait for new requests before launching the current request (for example, if the user
|
||||
// is still typing).
|
||||
delay: 100,
|
||||
|
||||
get(searchUrl, url, callback) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", url, true);
|
||||
xhr.timeout = 2500;
|
||||
// According to https://xhr.spec.whatwg.org/#request-error-steps,
|
||||
// readystatechange always gets called whether a request succeeds or not,
|
||||
// and the `readyState == 4` means an associated `state` is "done", which is true even if any error happens
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4)
|
||||
return callback(xhr.status === 200 ? xhr : null);
|
||||
};
|
||||
return xhr.send();
|
||||
},
|
||||
|
||||
// Look up the completion engine for this searchUrl. Because of DummyCompletionEngine, we know there will
|
||||
// always be a match.
|
||||
lookupEngine(searchUrl) {
|
||||
if (this.engineCache.has(searchUrl)) {
|
||||
return this.engineCache.get(searchUrl);
|
||||
} else {
|
||||
for (let engine of Array.from(CompletionEngines)) {
|
||||
engine = new engine();
|
||||
if (engine.match(searchUrl))
|
||||
return this.engineCache.set(searchUrl, engine);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// True if we have a completion engine for this search URL, false otherwise.
|
||||
haveCompletionEngine(searchUrl) {
|
||||
return !this.lookupEngine(searchUrl).dummy;
|
||||
},
|
||||
|
||||
// This is the main entry point.
|
||||
// - searchUrl is the search engine's URL, e.g. Settings.get("searchUrl"), or a custom search engine's URL.
|
||||
// This is only used as a key for determining the relevant completion engine.
|
||||
// - queryTerms are the query terms.
|
||||
// - callback will be applied to a list of suggestion strings (which may be an empty list, if anything goes
|
||||
// wrong).
|
||||
//
|
||||
// If no callback is provided, then we're to provide suggestions only if we can do so synchronously (ie.
|
||||
// from a cache). In this case we just return the results. Returns null if we cannot service the request
|
||||
// synchronously.
|
||||
//
|
||||
complete(searchUrl, queryTerms, callback = null) {
|
||||
let handler;
|
||||
const query = queryTerms.join(" ").toLowerCase();
|
||||
|
||||
const returnResultsOnlyFromCache = (callback == null);
|
||||
if (callback == null) { callback = suggestions => suggestions; }
|
||||
|
||||
// We don't complete queries which are too short: the results are usually useless.
|
||||
if (query.length < 4)
|
||||
return callback([]);
|
||||
|
||||
// We don't complete regular URLs or Javascript URLs.
|
||||
if (queryTerms.length == 1 && Utils.isUrl(query))
|
||||
return callback([]);
|
||||
if (Utils.hasJavascriptPrefix(query))
|
||||
return callback([]);
|
||||
|
||||
const completionCacheKey = JSON.stringify([ searchUrl, queryTerms ]);
|
||||
if (this.completionCache.has(completionCacheKey)) {
|
||||
if (this.debug)
|
||||
console.log("hit", completionCacheKey);
|
||||
return callback(this.completionCache.get(completionCacheKey));
|
||||
}
|
||||
|
||||
// If the user appears to be typing a continuation of the characters of the most recent query, then we can
|
||||
// sometimes re-use the previous suggestions.
|
||||
if ((this.mostRecentQuery != null) && (this.mostRecentSuggestions != null) && (this.mostRecentSearchUrl != null)) {
|
||||
if (searchUrl === this.mostRecentSearchUrl) {
|
||||
const reusePreviousSuggestions = (() => {
|
||||
// Verify that the previous query is a prefix of the current query.
|
||||
if (!query.startsWith(this.mostRecentQuery.toLowerCase()))
|
||||
return false;
|
||||
// Verify that every previous suggestion contains the text of the new query.
|
||||
// Note: @mostRecentSuggestions may also be empty, in which case we drop though. The effect is that
|
||||
// previous queries with no suggestions suppress subsequent no-hope HTTP requests as the user
|
||||
// continues to type.
|
||||
for (let suggestion of this.mostRecentSuggestions)
|
||||
if (!suggestion.includes(query))
|
||||
return false;
|
||||
// Ok. Re-use the suggestion.
|
||||
return true;
|
||||
})();
|
||||
|
||||
if (reusePreviousSuggestions) {
|
||||
if (this.debug)
|
||||
console.log("reuse previous query:", this.mostRecentQuery, this.mostRecentSuggestions.length);
|
||||
return callback(this.completionCache.set(completionCacheKey, this.mostRecentSuggestions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// That's all of the caches we can try. Bail if the caller is only requesting synchronous results. We
|
||||
// signal that we haven't found a match by returning null.
|
||||
if (returnResultsOnlyFromCache)
|
||||
return callback(null);
|
||||
|
||||
// We pause in case the user is still typing.
|
||||
Utils.setTimeout(this.delay, (handler = (this.mostRecentHandler = () => {
|
||||
if (handler !== this.mostRecentHandler)
|
||||
return;
|
||||
this.mostRecentHandler = null;
|
||||
|
||||
// Elide duplicate requests. First fetch the suggestions...
|
||||
if (this.inTransit[completionCacheKey] == null) {
|
||||
this.inTransit[completionCacheKey] = new AsyncDataFetcher(callback => {
|
||||
const engine = new EnginePrefixWrapper(searchUrl, this.lookupEngine(searchUrl));
|
||||
const url = engine.getUrl(queryTerms);
|
||||
|
||||
// TODO(philc): Do we need to return the result of this.get here, or can we remove this return statement?
|
||||
return this.get(searchUrl, url, (xhr = null) => {
|
||||
// Parsing the response may fail if we receive an unexpected or an unexpectedly-formatted response.
|
||||
// In all cases, we fall back to the catch clause, below. Therefore, we "fail safe" in the case of
|
||||
// incorrect or out-of-date completion engines.
|
||||
let suggestions;
|
||||
try {
|
||||
suggestions = engine.parse(xhr)
|
||||
// Make all suggestions lower case. It looks odd when suggestions from one completion engine are
|
||||
// upper case, and those from another are lower case.
|
||||
.map(s => s.toLowerCase())
|
||||
// Filter out the query itself. It's not adding anything.
|
||||
.filter(s => s !== query);
|
||||
if (this.debug)
|
||||
console.log("GET", url);
|
||||
} catch (error) {
|
||||
suggestions = [];
|
||||
// We allow failures to be cached too, but remove them after just thirty seconds.
|
||||
Utils.setTimeout(30 * 1000, () => this.completionCache.set(completionCacheKey, null));
|
||||
if (this.debug)
|
||||
console.log("fail", url);
|
||||
}
|
||||
|
||||
callback(suggestions);
|
||||
delete this.inTransit[completionCacheKey];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ... then use the suggestions.
|
||||
this.inTransit[completionCacheKey].use(suggestions => {
|
||||
this.mostRecentSearchUrl = searchUrl;
|
||||
this.mostRecentQuery = query;
|
||||
this.mostRecentSuggestions = suggestions;
|
||||
// TODO(philc): Is this return necessary?
|
||||
return callback(this.completionCache.set(completionCacheKey, suggestions));
|
||||
});
|
||||
})));
|
||||
},
|
||||
|
||||
// Cancel any pending (ie. blocked on @delay) queries. Does not cancel in-flight queries. This is called
|
||||
// whenever the user is typing.
|
||||
cancel() {
|
||||
if (this.mostRecentHandler != null) {
|
||||
this.mostRecentHandler = null;
|
||||
if (this.debug)
|
||||
console.log("cancel (user is typing)");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
global.CompletionSearch = CompletionSearch;
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
const ExclusionRegexpCache = {
|
||||
cache: {},
|
||||
clear(cache) {
|
||||
this.cache = cache || {};
|
||||
},
|
||||
get(pattern) {
|
||||
if (pattern in this.cache) {
|
||||
return this.cache[pattern];
|
||||
} else {
|
||||
let result;
|
||||
// We use try/catch to ensure that a broken regexp doesn't wholly cripple Vimium.
|
||||
try {
|
||||
result = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
|
||||
} catch (error) {
|
||||
BgUtils.log(`bad regexp in exclusion rule: ${pattern}`);
|
||||
result = /^$/; // Match the empty string.
|
||||
}
|
||||
this.cache[pattern] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The Exclusions class manages the exclusion rule setting. An exclusion is an object with two attributes:
|
||||
// pattern and passKeys. The exclusion rules are an array of such objects.
|
||||
var Exclusions = {
|
||||
// Make RegexpCache, which is required on the page popup, accessible via the Exclusions object.
|
||||
RegexpCache: ExclusionRegexpCache,
|
||||
|
||||
rules: Settings.get("exclusionRules"),
|
||||
|
||||
// Merge the matching rules for URL, or null. In the normal case, we use the configured @rules; hence, this
|
||||
// is the default. However, when called from the page popup, we are testing what effect candidate new rules
|
||||
// would have on the current tab. In this case, the candidate rules are provided by the caller.
|
||||
getRule(url, rules) {
|
||||
if (rules == null)
|
||||
rules = this.rules;
|
||||
const matchingRules = rules.filter(r => r.pattern && (url.search(ExclusionRegexpCache.get(r.pattern)) >= 0));
|
||||
// An absolute exclusion rule (one with no passKeys) takes priority.
|
||||
for (let rule of matchingRules)
|
||||
if (!rule.passKeys)
|
||||
return rule;
|
||||
// Strip whitespace from all matching passKeys strings, and join them together.
|
||||
const passKeys = matchingRules.map(r => r.passKeys.split(/\s+/).join("")).join("");
|
||||
// passKeys = (rule.passKeys.split(/\s+/).join "" for rule in matchingRules).join ""
|
||||
if (matchingRules.length > 0)
|
||||
return {passKeys: Utils.distinctCharacters(passKeys)};
|
||||
else
|
||||
return null;
|
||||
},
|
||||
|
||||
isEnabledForUrl(url) {
|
||||
const rule = Exclusions.getRule(url);
|
||||
return {
|
||||
isEnabledForUrl: !rule || (rule.passKeys.length > 0),
|
||||
passKeys: rule ? rule.passKeys : ""
|
||||
};
|
||||
},
|
||||
|
||||
setRules(rules) {
|
||||
// Callers map a rule to null to have it deleted, and rules without a pattern are useless.
|
||||
this.rules = rules.filter(rule => rule && rule.pattern);
|
||||
Settings.set("exclusionRules", this.rules);
|
||||
},
|
||||
|
||||
postUpdateHook(rules) {
|
||||
// NOTE(mrmr1993): In FF, the |rules| argument will be garbage collected when the exclusions popup is
|
||||
// closed. Do NOT store it/use it asynchronously.
|
||||
this.rules = Settings.get("exclusionRules");
|
||||
ExclusionRegexpCache.clear();
|
||||
}
|
||||
};
|
||||
|
||||
// Register postUpdateHook for exclusionRules setting.
|
||||
Settings.postUpdateHooks["exclusionRules"] = Exclusions.postUpdateHook.bind(Exclusions);
|
||||
|
||||
global.Exclusions = Exclusions;
|
||||
|
|
@ -0,0 +1,719 @@
|
|||
// NOTE(philc): This file has many superfluous return statements in its functions, as a result of
|
||||
// converting from coffeescript to es6. Many can be removed, but I didn't take the time to diligently
|
||||
// track down precisely which return statements could be removed when I was doing the conversion.
|
||||
|
||||
let showUpgradeMessage;
|
||||
|
||||
// The browser may have tabs already open. We inject the content scripts immediately so that they work straight
|
||||
// away.
|
||||
chrome.runtime.onInstalled.addListener(function({ reason }) {
|
||||
// See https://developer.chrome.com/extensions/runtime#event-onInstalled
|
||||
if ([ "chrome_update", "shared_module_update" ].includes(reason)) { return; }
|
||||
if (Utils.isFirefox()) { return; }
|
||||
const manifest = chrome.runtime.getManifest();
|
||||
// Content scripts loaded on every page should be in the same group. We assume it is the first.
|
||||
const contentScripts = manifest.content_scripts[0];
|
||||
const jobs = [ [ chrome.tabs.executeScript, contentScripts.js ], [ chrome.tabs.insertCSS, contentScripts.css ] ];
|
||||
// Chrome complains if we don't evaluate chrome.runtime.lastError on errors (and we get errors for tabs on
|
||||
// which Vimium cannot run).
|
||||
const checkLastRuntimeError = () => chrome.runtime.lastError;
|
||||
return chrome.tabs.query({ status: "complete" }, function(tabs) {
|
||||
for (let tab of tabs)
|
||||
for (let [ func, files ] of jobs)
|
||||
for (let file of files)
|
||||
func(tab.id, { file, allFrames: contentScripts.all_frames }, checkLastRuntimeError);
|
||||
});
|
||||
});
|
||||
|
||||
const frameIdsForTab = {};
|
||||
global.portsForTab = {};
|
||||
global.urlForTab = {};
|
||||
|
||||
// This is exported for use by "marks.js".
|
||||
global.tabLoadedHandlers = {}; // tabId -> function()
|
||||
|
||||
// A secret, available only within the current instantiation of Vimium, for the duration of the browser
|
||||
// session. The secret is a generated strong random string.
|
||||
const randomArray = window.crypto.getRandomValues(new Uint8Array(32)); // 32-byte random token.
|
||||
const secretToken = randomArray.reduce((a,b) => a.toString(16) + b.toString(16));
|
||||
chrome.storage.local.set({vimiumSecret: secretToken});
|
||||
|
||||
const completionSources = {
|
||||
bookmarks: new BookmarkCompleter,
|
||||
history: new HistoryCompleter,
|
||||
domains: new DomainCompleter,
|
||||
tabs: new TabCompleter,
|
||||
searchEngines: new SearchEngineCompleter
|
||||
};
|
||||
|
||||
const completers = {
|
||||
omni: new MultiCompleter([
|
||||
completionSources.bookmarks,
|
||||
completionSources.history,
|
||||
completionSources.domains,
|
||||
completionSources.tabs,
|
||||
completionSources.searchEngines
|
||||
]),
|
||||
bookmarks: new MultiCompleter([completionSources.bookmarks]),
|
||||
tabs: new MultiCompleter([completionSources.tabs])
|
||||
};
|
||||
|
||||
const completionHandlers = {
|
||||
filter(completer, request, port) {
|
||||
// TODO(philc): Do we need any of these return statements?
|
||||
return completer.filter(request, function(response) {
|
||||
// NOTE(smblott): response contains `relevancyFunction` (function) properties which cause postMessage,
|
||||
// below, to fail in Firefox. See #2576. We cannot simply delete these methods, as they're needed
|
||||
// elsewhere. Converting the response to JSON and back is a quick and easy way to sanitize the object.
|
||||
response = JSON.parse(JSON.stringify(response));
|
||||
// We use try here because this may fail if the sender has already navigated away from the original page.
|
||||
// This can happen, for example, when posting completion suggestions from the SearchEngineCompleter
|
||||
// (which is done asynchronously).
|
||||
try {
|
||||
return port.postMessage(Object.assign(request, response, {handler: "completions"}));
|
||||
} catch (error) {}
|
||||
});
|
||||
},
|
||||
|
||||
refresh(completer, _, port) { completer.refresh(port); },
|
||||
cancel(completer, _, port) { completer.cancel(port); }
|
||||
};
|
||||
|
||||
const handleCompletions = sender => (request, port) =>
|
||||
completionHandlers[request.handler](completers[request.name], request, port);
|
||||
|
||||
chrome.runtime.onConnect.addListener(function(port) {
|
||||
if (portHandlers[port.name])
|
||||
return port.onMessage.addListener(portHandlers[port.name](port.sender, port));
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
|
||||
request = Object.assign({count: 1, frameId: sender.frameId},
|
||||
request,
|
||||
{tab: sender.tab, tabId: sender.tab.id});
|
||||
if (sendRequestHandlers[request.handler]) {
|
||||
sendResponse(sendRequestHandlers[request.handler](request, sender));
|
||||
}
|
||||
// Ensure that the sendResponse callback is freed.
|
||||
return false;
|
||||
});
|
||||
|
||||
const onURLChange = details => chrome.tabs.sendMessage(details.tabId, {name: "checkEnabledAfterURLChange"});
|
||||
|
||||
// Re-check whether Vimium is enabled for a frame when the url changes without a reload.
|
||||
chrome.webNavigation.onHistoryStateUpdated.addListener(onURLChange); // history.pushState.
|
||||
chrome.webNavigation.onReferenceFragmentUpdated.addListener(onURLChange); // Hash changed.
|
||||
|
||||
// Cache "content_scripts/vimium.css" in chrome.storage.local for UI components.
|
||||
(function() {
|
||||
const req = new XMLHttpRequest();
|
||||
req.open("GET", chrome.runtime.getURL("content_scripts/vimium.css"), true); // true -> asynchronous.
|
||||
req.onload = function() {
|
||||
const {status, responseText} = req;
|
||||
if (status === 200)
|
||||
return chrome.storage.local.set({vimiumCSSInChromeStorage: responseText});
|
||||
};
|
||||
return req.send();
|
||||
})();
|
||||
|
||||
const TabOperations = {
|
||||
// Opens the url in the current tab.
|
||||
openUrlInCurrentTab(request) {
|
||||
if (Utils.hasJavascriptPrefix(request.url)) {
|
||||
const tabId = request.tabId;
|
||||
const frameId = request.frameId;
|
||||
chrome.tabs.sendMessage(tabId, {frameId, name: "executeScript", script: request.url});
|
||||
} else {
|
||||
chrome.tabs.update(request.tabId, {url: Utils.convertToUrl(request.url)});
|
||||
}
|
||||
},
|
||||
|
||||
// Opens request.url in new tab and switches to it.
|
||||
openUrlInNewTab(request, callback) {
|
||||
if (callback == null)
|
||||
callback = function() {};
|
||||
const tabConfig = {
|
||||
url: Utils.convertToUrl(request.url),
|
||||
active: true,
|
||||
windowId: request.tab.windowId
|
||||
};
|
||||
|
||||
const position = request.position;
|
||||
|
||||
let tabIndex = null;
|
||||
|
||||
// TODO(philc): Convert to a switch statement ES6.
|
||||
switch (position) {
|
||||
case "start": tabIndex = 0; break;
|
||||
case "before": tabIndex = request.tab.index; break;
|
||||
// if on Chrome or on Firefox but without openerTabId, `tabs.create` opens a tab at the end.
|
||||
// but on Firefox and with openerTabId, it opens a new tab next to the opener tab
|
||||
case "end": tabIndex = (Utils.isFirefox() ? 9999 : null); break;
|
||||
// "after" is the default case when there are no options.
|
||||
default: tabIndex = request.tab.index + 1;
|
||||
}
|
||||
tabConfig.index = tabIndex;
|
||||
|
||||
if (request.active != null)
|
||||
tabConfig.active = request.active;
|
||||
// Firefox does not support "about:newtab" in chrome.tabs.create.
|
||||
if (tabConfig["url"] === Settings.defaults.newTabUrl)
|
||||
delete tabConfig["url"];
|
||||
|
||||
// Firefox <57 throws an error when openerTabId is used (issue 1238314).
|
||||
const canUseOpenerTabId = !(Utils.isFirefox() && (Utils.compareVersions(Utils.firefoxVersion(), "57") < 0));
|
||||
if (canUseOpenerTabId)
|
||||
tabConfig.openerTabId = request.tab.id;
|
||||
|
||||
// clean position and active, so following `openUrlInNewTab(request)` will create a tab just next to this new tab
|
||||
return chrome.tabs.create(tabConfig, tab =>
|
||||
callback(Object.assign(request, {tab, tabId: tab.id, position: "", active: false})));
|
||||
},
|
||||
|
||||
// Opens request.url in new window and switches to it.
|
||||
openUrlInNewWindow(request, callback) {
|
||||
if (callback == null)
|
||||
callback = function() {};
|
||||
const winConfig = {
|
||||
url: Utils.convertToUrl(request.url),
|
||||
active: true
|
||||
};
|
||||
if (request.active != null)
|
||||
winConfig.active = request.active;
|
||||
// Firefox does not support "about:newtab" in chrome.tabs.create.
|
||||
if (winConfig["url"] === Settings.defaults.newTabUrl)
|
||||
delete winConfig["url"];
|
||||
return chrome.windows.create(winConfig, callback);
|
||||
}
|
||||
};
|
||||
|
||||
const muteTab = tab => chrome.tabs.update(tab.id, {muted: !tab.mutedInfo.muted});
|
||||
const toggleMuteTab = function({tab: currentTab, registryEntry, tabId, frameId}) {
|
||||
if ((registryEntry.options.all != null) || (registryEntry.options.other != null)) {
|
||||
// If there are any audible, unmuted tabs, then we mute them; otherwise we unmute any muted tabs.
|
||||
chrome.tabs.query({audible: true}, function(tabs) {
|
||||
let tab;
|
||||
if (registryEntry.options.other != null)
|
||||
tabs = tabs.filter(t => t.id !== currentTab.id);
|
||||
const audibleUnmutedTabs = tabs.filter(t => t.audible && !t.mutedInfo.muted);
|
||||
if (audibleUnmutedTabs.length >= 0) {
|
||||
chrome.tabs.sendMessage(tabId, {frameId, name: "showMessage", message: `Muting ${audibleUnmutedTabs.length} tab(s).`});
|
||||
for (tab of audibleUnmutedTabs)
|
||||
muteTab(tab);
|
||||
} else {
|
||||
chrome.tabs.sendMessage(tabId, {frameId, name: "showMessage", message: "Unmuting all muted tabs."});
|
||||
for (tab of tabs)
|
||||
if (tab.mutedInfo.muted)
|
||||
muteTab(tab);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (currentTab.mutedInfo.muted)
|
||||
chrome.tabs.sendMessage(tabId, {frameId, name: "showMessage", message: "Unmuted tab."});
|
||||
else
|
||||
chrome.tabs.sendMessage(tabId, {frameId, name: "showMessage", message: "Muted tab."});
|
||||
muteTab(currentTab);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Selects the tab with the ID specified in request.id
|
||||
//
|
||||
const selectSpecificTab = request => chrome.tabs.get(request.id, function(tab) {
|
||||
if (chrome.windows != null)
|
||||
chrome.windows.update(tab.windowId, { focused: true });
|
||||
return chrome.tabs.update(request.id, { active: true });
|
||||
});
|
||||
|
||||
const moveTab = function({count, tab, registryEntry}) {
|
||||
if (registryEntry.command === "moveTabLeft")
|
||||
count = -count;
|
||||
return chrome.tabs.query({ currentWindow: true }, function(tabs) {
|
||||
const pinnedCount = (tabs.filter(tab => tab.pinned)).length;
|
||||
const minIndex = tab.pinned ? 0 : pinnedCount;
|
||||
const maxIndex = (tab.pinned ? pinnedCount : tabs.length) - 1;
|
||||
return chrome.tabs.move(tab.id,
|
||||
{index: Math.max(minIndex, Math.min(maxIndex, tab.index + count))});
|
||||
});
|
||||
};
|
||||
|
||||
var mkRepeatCommand = command => (function(request) {
|
||||
request.count--;
|
||||
if (request.count >= 0)
|
||||
return command(request, request => (mkRepeatCommand(command))(request));
|
||||
});
|
||||
|
||||
// These are commands which are bound to keystrokes which must be handled by the background page. They are
|
||||
// mapped in commands.coffee.
|
||||
const BackgroundCommands = {
|
||||
// Create a new tab. Also, with:
|
||||
// map X createTab http://www.bbc.com/news
|
||||
// create a new tab with the given URL.
|
||||
createTab: mkRepeatCommand(function(request, callback) {
|
||||
if (request.urls == null) {
|
||||
if (request.url) {
|
||||
// If the request contains a URL, then use it.
|
||||
request.urls = [request.url];
|
||||
} else {
|
||||
// Otherwise, if we have a registryEntry containing URLs, then use them.
|
||||
const urlList = request.registryEntry.optionList.filter(opt => Utils.isUrl(opt));
|
||||
if (urlList.length > 0) {
|
||||
request.urls = urlList;
|
||||
} else {
|
||||
// Otherwise, just create a new tab.
|
||||
const newTabUrl = Settings.get("newTabUrl");
|
||||
if (newTabUrl === "pages/blank.html") {
|
||||
// "pages/blank.html" does not work in incognito mode, so fall back to "chrome://newtab" instead.
|
||||
request.urls = [request.tab.incognito ? "chrome://newtab" : chrome.runtime.getURL(newTabUrl)];
|
||||
} else {
|
||||
request.urls = [newTabUrl];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.registryEntry.options.incognito || request.registryEntry.options.window) {
|
||||
const windowConfig = {
|
||||
url: request.urls,
|
||||
incognito: request.registryEntry.options.incognito || false
|
||||
};
|
||||
return chrome.windows.create(windowConfig, () => callback(request));
|
||||
} else {
|
||||
let openNextUrl;
|
||||
const urls = request.urls.slice().reverse();
|
||||
if (request.position == null)
|
||||
request.position = request.registryEntry.options.position;
|
||||
return (openNextUrl = function(request) {
|
||||
if (urls.length > 0)
|
||||
return TabOperations.openUrlInNewTab((Object.assign(request, {url: urls.pop()})), openNextUrl);
|
||||
else
|
||||
return callback(request);
|
||||
})(request);
|
||||
}
|
||||
}),
|
||||
|
||||
duplicateTab: mkRepeatCommand((request, callback) => {
|
||||
return chrome.tabs.duplicate(request.tabId,
|
||||
tab => callback(Object.assign(request, {tab, tabId: tab.id})))
|
||||
}),
|
||||
|
||||
moveTabToNewWindow({count, tab}) {
|
||||
chrome.tabs.query({currentWindow: true}, function(tabs) {
|
||||
const activeTabIndex = tab.index;
|
||||
const startTabIndex = Math.max(0, Math.min(activeTabIndex, tabs.length - count));
|
||||
[ tab, ...tabs ] = tabs.slice(startTabIndex, startTabIndex + count);
|
||||
chrome.windows.create({tabId: tab.id, incognito: tab.incognito}, function(window) {
|
||||
chrome.tabs.move(tabs.map(t => t.id), {windowId: window.id, index: -1});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
nextTab(request) { return selectTab("next", request); },
|
||||
previousTab(request) { return selectTab("previous", request); },
|
||||
firstTab(request) { return selectTab("first", request); },
|
||||
lastTab(request) { return selectTab("last", request); },
|
||||
removeTab({count, tab}) { return forCountTabs(count, tab, tab => chrome.tabs.remove(tab.id)); },
|
||||
restoreTab: mkRepeatCommand((request, callback) => chrome.sessions.restore(null, callback(request))),
|
||||
togglePinTab({count, tab}) { return forCountTabs(count, tab, tab => chrome.tabs.update(tab.id, {pinned: !tab.pinned})); },
|
||||
toggleMuteTab,
|
||||
moveTabLeft: moveTab,
|
||||
moveTabRight: moveTab,
|
||||
|
||||
nextFrame({count, frameId, tabId}) {
|
||||
frameIdsForTab[tabId] = cycleToFrame(frameIdsForTab[tabId], frameId, count);
|
||||
return chrome.tabs.sendMessage(tabId, {name: "focusFrame", frameId: frameIdsForTab[tabId][0], highlight: true});
|
||||
},
|
||||
|
||||
closeTabsOnLeft(request) { return removeTabsRelative("before", request); },
|
||||
closeTabsOnRight(request) { return removeTabsRelative("after", request); },
|
||||
closeOtherTabs(request) { return removeTabsRelative("both", request); },
|
||||
|
||||
visitPreviousTab({count, tab}) {
|
||||
const tabIds = BgUtils.tabRecency.getTabsByRecency().filter(tabId => tabId !== tab.id);
|
||||
if (tabIds.length > 0)
|
||||
return selectSpecificTab({id: tabIds[(count-1) % tabIds.length]});
|
||||
},
|
||||
|
||||
reload({count, tabId, registryEntry, tab: {windowId}}){
|
||||
const bypassCache = registryEntry.options.hard != null ? registryEntry.options.hard : false;
|
||||
return chrome.tabs.query({windowId}, function(tabs) {
|
||||
const position = (function() {
|
||||
for (let index = 0; index < tabs.length; index++) {
|
||||
const tab = tabs[index];
|
||||
if (tab.id === tabId) { return index; }
|
||||
}
|
||||
})();
|
||||
tabs = [...tabs.slice(position), ...tabs.slice(0, position)];
|
||||
count = Math.min(count, tabs.length);
|
||||
for (let tab of tabs.slice(0, count)) {
|
||||
chrome.tabs.reload(tab.id, {bypassCache});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var forCountTabs = (count, currentTab, callback) => chrome.tabs.query({currentWindow: true}, function(tabs) {
|
||||
const activeTabIndex = currentTab.index;
|
||||
const startTabIndex = Math.max(0, Math.min(activeTabIndex, tabs.length - count));
|
||||
for (let tab of tabs.slice(startTabIndex, startTabIndex + count))
|
||||
callback(tab);
|
||||
});
|
||||
|
||||
// Remove tabs before, after, or either side of the currently active tab
|
||||
var removeTabsRelative = (direction, {tab: activeTab}) => chrome.tabs.query({currentWindow: true}, function(tabs) {
|
||||
const shouldDelete =
|
||||
(() => { switch (direction) {
|
||||
case "before":
|
||||
return index => index < activeTab.index;
|
||||
case "after":
|
||||
return index => index > activeTab.index;
|
||||
case "both":
|
||||
return index => index !== activeTab.index;
|
||||
} })();
|
||||
|
||||
chrome.tabs.remove(tabs.filter(t => !t.pinned && shouldDelete(t.index))
|
||||
.map((t) => t.id));
|
||||
});
|
||||
|
||||
// Selects a tab before or after the currently selected tab.
|
||||
// - direction: "next", "previous", "first" or "last".
|
||||
var selectTab = (direction, {count, tab}) => chrome.tabs.query({ currentWindow: true }, function(tabs) {
|
||||
if (tabs.length > 1) {
|
||||
const toSelect =
|
||||
(() => { switch (direction) {
|
||||
case "next":
|
||||
return (tab.index + count) % tabs.length;
|
||||
case "previous":
|
||||
return ((tab.index - count) + (count * tabs.length)) % tabs.length;
|
||||
case "first":
|
||||
return Math.min(tabs.length - 1, count - 1);
|
||||
case "last":
|
||||
return Math.max(0, tabs.length - count);
|
||||
} })();
|
||||
chrome.tabs.update(tabs[toSelect].id, {active: true});
|
||||
}
|
||||
});
|
||||
|
||||
chrome.webNavigation.onCommitted.addListener(function({tabId, frameId}) {
|
||||
const cssConf = {
|
||||
frameId,
|
||||
code: Settings.get("userDefinedLinkHintCss"),
|
||||
runAt: "document_start"
|
||||
};
|
||||
return chrome.tabs.insertCSS(tabId, cssConf, () => chrome.runtime.lastError);
|
||||
});
|
||||
|
||||
// Symbolic names for the three browser-action icons.
|
||||
const ENABLED_ICON = "icons/browser_action_enabled.png";
|
||||
const DISABLED_ICON = "icons/browser_action_disabled.png";
|
||||
const PARTIAL_ICON = "icons/browser_action_partial.png";
|
||||
|
||||
// Convert the three icon PNGs to image data.
|
||||
const iconImageData = {};
|
||||
for (let icon of [ENABLED_ICON, DISABLED_ICON, PARTIAL_ICON]) {
|
||||
iconImageData[icon] = {};
|
||||
for (let scale of [19, 38]) {
|
||||
(function(icon, scale) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = (canvas.height = scale);
|
||||
// We cannot do the rest of this in the tests.
|
||||
if ((chrome.areRunningVimiumTests == null) || !chrome.areRunningVimiumTests) {
|
||||
const context = canvas.getContext("2d");
|
||||
const image = new Image;
|
||||
image.src = icon;
|
||||
image.onload = function() {
|
||||
context.drawImage(image, 0, 0, scale, scale);
|
||||
iconImageData[icon][scale] = context.getImageData(0, 0, scale, scale);
|
||||
return document.body.removeChild(canvas);
|
||||
};
|
||||
return document.body.appendChild(canvas);
|
||||
}
|
||||
})(icon, scale);
|
||||
}
|
||||
}
|
||||
|
||||
var Frames = {
|
||||
onConnect(sender, port) {
|
||||
const [tabId, frameId] = [sender.tab.id, sender.frameId];
|
||||
port.onDisconnect.addListener(() => Frames.unregisterFrame({tabId, frameId, port}));
|
||||
port.postMessage({handler: "registerFrameId", chromeFrameId: frameId});
|
||||
(portsForTab[tabId] != null ? portsForTab[tabId] : (portsForTab[tabId] = {}))[frameId] = port;
|
||||
|
||||
// Return our onMessage handler for this port.
|
||||
return (request, port) => {
|
||||
return this[request.handler]({request, tabId, frameId, port, sender});
|
||||
};
|
||||
},
|
||||
|
||||
registerFrame({tabId, frameId, port}) {
|
||||
frameIdsForTab[tabId] = frameIdsForTab[tabId] || [];
|
||||
if (!frameIdsForTab[tabId].includes(frameId)) {
|
||||
frameIdsForTab[tabId].push(frameId);
|
||||
}
|
||||
portsForTab[tabId] = portsForTab[tabId] || {};
|
||||
return portsForTab[tabId][frameId] = port;
|
||||
},
|
||||
|
||||
unregisterFrame({tabId, frameId, port}) {
|
||||
// Check that the port trying to unregister the frame hasn't already been replaced by a new frame
|
||||
// registering. See #2125.
|
||||
const registeredPort = portsForTab[tabId] != null ? portsForTab[tabId][frameId] : undefined;
|
||||
if ((registeredPort === port) || !registeredPort) {
|
||||
if (tabId in frameIdsForTab)
|
||||
frameIdsForTab[tabId] = frameIdsForTab[tabId].filter((fId) => fId !== frameId);
|
||||
if (tabId in portsForTab)
|
||||
delete portsForTab[tabId][frameId];
|
||||
}
|
||||
HintCoordinator.unregisterFrame(tabId, frameId);
|
||||
},
|
||||
|
||||
isEnabledForUrl({request, tabId, port}) {
|
||||
if (request.frameIsFocused)
|
||||
urlForTab[tabId] = request.url;
|
||||
request.isFirefox = Utils.isFirefox(); // Update the value for Utils.isFirefox in the frontend.
|
||||
const enabledState = Exclusions.isEnabledForUrl(request.url);
|
||||
|
||||
if (request.frameIsFocused) {
|
||||
if (chrome.browserAction.setIcon) {
|
||||
chrome.browserAction.setIcon({tabId, imageData: (function() {
|
||||
const enabledStateIcon =
|
||||
!enabledState.isEnabledForUrl ?
|
||||
DISABLED_ICON
|
||||
: enabledState.passKeys.length >= 0 ?
|
||||
PARTIAL_ICON
|
||||
:
|
||||
ENABLED_ICON;
|
||||
return iconImageData[enabledStateIcon];
|
||||
})()});
|
||||
}
|
||||
}
|
||||
|
||||
return port.postMessage(Object.assign(request, enabledState));
|
||||
},
|
||||
|
||||
domReady({tabId, frameId}) {
|
||||
if (frameId == 0) {
|
||||
if (tabLoadedHandlers[tabId])
|
||||
tabLoadedHandlers[tabId]();
|
||||
delete tabLoadedHandlers[tabId];
|
||||
}
|
||||
},
|
||||
|
||||
linkHintsMessage({request, tabId, frameId}) {
|
||||
HintCoordinator.onMessage(tabId, frameId, request);
|
||||
},
|
||||
|
||||
// For debugging only. This allows content scripts to log messages to the extension's logging page.
|
||||
log({frameId, sender, request: {message}}) {
|
||||
BgUtils.log(`${frameId} ${message}`, sender);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFrameFocused = function({tabId, frameId}) {
|
||||
if (frameIdsForTab[tabId] == null)
|
||||
frameIdsForTab[tabId] = [];
|
||||
frameIdsForTab[tabId] = cycleToFrame(frameIdsForTab[tabId], frameId);
|
||||
// Inform all frames that a frame has received the focus.
|
||||
return chrome.tabs.sendMessage(tabId, {name: "frameFocused", focusFrameId: frameId});
|
||||
};
|
||||
|
||||
// Rotate through frames to the frame count places after frameId.
|
||||
var cycleToFrame = function(frames, frameId, count) {
|
||||
// We can't always track which frame chrome has focused, but here we learn that it's frameId; so add an
|
||||
// additional offset such that we do indeed start from frameId.
|
||||
if (count == null)
|
||||
count = 0;
|
||||
count = (count + Math.max(0, frames.indexOf(frameId))) % frames.length;
|
||||
return [...frames.slice(count), ...frames.slice(0, count)];
|
||||
};
|
||||
|
||||
var HintCoordinator = {
|
||||
tabState: {},
|
||||
|
||||
onMessage(tabId, frameId, request) {
|
||||
if (request.messageType in this) {
|
||||
return this[request.messageType](tabId, frameId, request);
|
||||
} else {
|
||||
// If there's no handler here, then the message is forwarded to all frames in the sender's tab.
|
||||
return this.sendMessage(request.messageType, tabId, request);
|
||||
}
|
||||
},
|
||||
|
||||
// Post a link-hints message to a particular frame's port. We catch errors in case the frame has gone away.
|
||||
postMessage(tabId, frameId, messageType, port, request) {
|
||||
if (request == null)
|
||||
request = {};
|
||||
try {
|
||||
return port.postMessage(Object.assign(request, {handler: "linkHintsMessage", messageType}));
|
||||
} catch (error) {
|
||||
return this.unregisterFrame(tabId, frameId);
|
||||
}
|
||||
},
|
||||
|
||||
// Post a link-hints message to all participating frames.
|
||||
sendMessage(messageType, tabId, request) {
|
||||
if (request == null)
|
||||
request = {};
|
||||
|
||||
for (let frameId of Object.keys(this.tabState[tabId].ports || {})) {
|
||||
const port = this.tabState[tabId].ports[frameId];
|
||||
this.postMessage(tabId, parseInt(frameId), messageType, port, request)
|
||||
}
|
||||
},
|
||||
|
||||
prepareToActivateMode(tabId, originatingFrameId, {modeIndex, isVimiumHelpDialog}) {
|
||||
this.tabState[tabId] = {frameIds: frameIdsForTab[tabId].slice(), hintDescriptors: {}, originatingFrameId, modeIndex};
|
||||
this.tabState[tabId].ports = {};
|
||||
frameIdsForTab[tabId].map(frameId => {
|
||||
return this.tabState[tabId].ports[frameId] = portsForTab[tabId][frameId];
|
||||
});
|
||||
this.sendMessage("getHintDescriptors", tabId, {modeIndex, isVimiumHelpDialog});
|
||||
},
|
||||
|
||||
// Receive hint descriptors from all frames and activate link-hints mode when we have them all.
|
||||
postHintDescriptors(tabId, frameId, {hintDescriptors}) {
|
||||
if (!this.tabState[tabId].frameIds.includes(frameId)) {
|
||||
return;
|
||||
}
|
||||
this.tabState[tabId].hintDescriptors[frameId] = hintDescriptors;
|
||||
this.tabState[tabId].frameIds = this.tabState[tabId].frameIds.filter(fId => fId !== frameId);
|
||||
if (this.tabState[tabId].frameIds.length === 0) {
|
||||
for (frameId of Object.keys(this.tabState[tabId].ports || {})) {
|
||||
const port = this.tabState[tabId].ports[frameId];
|
||||
if (frameId in this.tabState[tabId].hintDescriptors) {
|
||||
hintDescriptors = Object.assign({}, this.tabState[tabId].hintDescriptors);
|
||||
// We do not send back the frame's own hint descriptors. This is faster (approx. speedup 3/2) for
|
||||
// link-busy sites like reddit.
|
||||
delete hintDescriptors[frameId];
|
||||
this.postMessage(tabId, parseInt(frameId), "activateMode", port, {
|
||||
originatingFrameId: this.tabState[tabId].originatingFrameId,
|
||||
hintDescriptors,
|
||||
modeIndex: this.tabState[tabId].modeIndex
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// If an unregistering frame is participating in link-hints mode, then we need to tidy up after it.
|
||||
unregisterFrame(tabId, frameId) {
|
||||
if (!this.tabState[tabId])
|
||||
return;
|
||||
if ((this.tabState[tabId].ports != null ? this.tabState[tabId].ports[frameId] : undefined) != null)
|
||||
delete this.tabState[tabId].ports[frameId];
|
||||
if ((this.tabState[tabId].frameIds != null) && this.tabState[tabId].frameIds.includes(frameId)) {
|
||||
// We fake an empty "postHintDescriptors" because the frame has gone away.
|
||||
return this.postHintDescriptors(tabId, frameId, {hintDescriptors: []});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Port handler mapping
|
||||
var portHandlers = {
|
||||
completions: handleCompletions,
|
||||
frames: Frames.onConnect.bind(Frames)
|
||||
};
|
||||
|
||||
var sendRequestHandlers = {
|
||||
runBackgroundCommand(request) { return BackgroundCommands[request.registryEntry.command](request); },
|
||||
// getCurrentTabUrl is used by the content scripts to get their full URL, because window.location cannot help
|
||||
// with Chrome-specific URLs like "view-source:http:..".
|
||||
getCurrentTabUrl({tab}) { return tab.url; },
|
||||
openUrlInNewTab: mkRepeatCommand((request, callback) => TabOperations.openUrlInNewTab(request, callback)),
|
||||
openUrlInNewWindow(request) { return TabOperations.openUrlInNewWindow(request); },
|
||||
openUrlInIncognito(request) { return chrome.windows.create({incognito: true, url: Utils.convertToUrl(request.url)}); },
|
||||
openUrlInCurrentTab: TabOperations.openUrlInCurrentTab,
|
||||
openOptionsPageInNewTab(request) {
|
||||
return chrome.tabs.create({url: chrome.runtime.getURL("pages/options.html"), index: request.tab.index + 1});
|
||||
},
|
||||
frameFocused: handleFrameFocused,
|
||||
nextFrame: BackgroundCommands.nextFrame,
|
||||
selectSpecificTab,
|
||||
createMark: Marks.create.bind(Marks),
|
||||
gotoMark: Marks.goto.bind(Marks),
|
||||
// Send a message to all frames in the current tab.
|
||||
sendMessageToFrames(request, sender) { return chrome.tabs.sendMessage(sender.tab.id, request.message); }
|
||||
};
|
||||
|
||||
// We always remove chrome.storage.local/findModeRawQueryListIncognito on startup.
|
||||
chrome.storage.local.remove("findModeRawQueryListIncognito");
|
||||
|
||||
// Tidy up tab caches when tabs are removed. Also remove chrome.storage.local/findModeRawQueryListIncognito if
|
||||
// there are no remaining incognito-mode windows. Since the common case is that there are none to begin with,
|
||||
// we first check whether the key is set at all.
|
||||
chrome.tabs.onRemoved.addListener(function(tabId) {
|
||||
for (let cache of [frameIdsForTab, urlForTab, portsForTab, HintCoordinator.tabState]) { delete cache[tabId]; }
|
||||
return chrome.storage.local.get("findModeRawQueryListIncognito", function(items) {
|
||||
if (items.findModeRawQueryListIncognito) {
|
||||
return chrome.windows != null ? chrome.windows.getAll(null, function(windows) {
|
||||
for (let window of windows) {
|
||||
if (window.incognito)
|
||||
return;
|
||||
}
|
||||
// There are no remaining incognito-mode tabs, and findModeRawQueryListIncognito is set.
|
||||
return chrome.storage.local.remove("findModeRawQueryListIncognito");
|
||||
}) : undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Convenience function for development use.
|
||||
window.runTests = () => open(chrome.runtime.getURL('tests/dom_tests/dom_tests.html'));
|
||||
|
||||
//
|
||||
// Begin initialization.
|
||||
//
|
||||
|
||||
// Show notification on upgrade.
|
||||
(showUpgradeMessage = function() {
|
||||
const currentVersion = Utils.getCurrentVersion();
|
||||
// Avoid showing the upgrade notification when previousVersion is undefined, which is the case for new
|
||||
// installs.
|
||||
if (!Settings.has("previousVersion"))
|
||||
Settings.set("previousVersion", currentVersion);
|
||||
const previousVersion = Settings.get("previousVersion");
|
||||
if (Utils.compareVersions(currentVersion, previousVersion ) === 1) {
|
||||
const currentVersionNumbers = currentVersion.split(".");
|
||||
const previousVersionNumbers = previousVersion.split(".");
|
||||
if (currentVersionNumbers.slice(0, 2).join(".") === previousVersionNumbers.slice(0, 2).join(".")) {
|
||||
// We do not show an upgrade message for patch/silent releases. Such releases have the same major and
|
||||
// minor version numbers. We do, however, update the recorded previous version.
|
||||
Settings.set("previousVersion", currentVersion);
|
||||
} else {
|
||||
const notificationId = "VimiumUpgradeNotification";
|
||||
const notification = {
|
||||
type: "basic",
|
||||
iconUrl: chrome.runtime.getURL("icons/vimium.png"),
|
||||
title: "Vimium Upgrade",
|
||||
message: `Vimium has been upgraded to version ${currentVersion}. Click here for more information.`,
|
||||
isClickable: true
|
||||
};
|
||||
if (chrome.notifications && chrome.notifications.create) {
|
||||
chrome.notifications.create(notificationId, notification, function() {
|
||||
if (!chrome.runtime.lastError) {
|
||||
Settings.set("previousVersion", currentVersion);
|
||||
chrome.notifications.onClicked.addListener(function(id) {
|
||||
if (id === notificationId) {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, function(...args) {
|
||||
const [tab] = args[0];
|
||||
return TabOperations.openUrlInNewTab({tab, tabId: tab.id, url: "https://github.com/philc/vimium/blob/master/CHANGELOG.md"});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// We need to wait for the user to accept the "notifications" permission.
|
||||
chrome.permissions.onAdded.addListener(showUpgradeMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// The install date is shown on the logging page.
|
||||
chrome.runtime.onInstalled.addListener(function({reason}) {
|
||||
if (!["chrome_update", "shared_module_update"].includes(reason))
|
||||
chrome.storage.local.set({installDate: new Date().toString()});
|
||||
});
|
||||
|
||||
Object.assign(global, {TabOperations, Frames});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
const Marks = {
|
||||
// This returns the key which is used for storing mark locations in chrome.storage.sync.
|
||||
getLocationKey(markName) { return `vimiumGlobalMark|${markName}`; },
|
||||
|
||||
// Get the part of a URL we use for matching here (that is, everything up to the first anchor).
|
||||
getBaseUrl(url) { return url.split("#")[0]; },
|
||||
|
||||
// Create a global mark. We record vimiumSecret with the mark so that we can tell later, when the mark is
|
||||
// used, whether this is the original Vimium session or a subsequent session. This affects whether or not
|
||||
// tabId can be considered valid.
|
||||
create(req, sender) {
|
||||
chrome.storage.local.get("vimiumSecret", items => {
|
||||
const markInfo = {
|
||||
vimiumSecret: items.vimiumSecret,
|
||||
markName: req.markName,
|
||||
url: this.getBaseUrl(sender.tab.url),
|
||||
tabId: sender.tab.id,
|
||||
scrollX: req.scrollX,
|
||||
scrollY: req.scrollY
|
||||
};
|
||||
|
||||
if ((markInfo.scrollX != null) && (markInfo.scrollY != null)) {
|
||||
return this.saveMark(markInfo);
|
||||
} else {
|
||||
// The front-end frame hasn't provided the scroll position (because it's not the top frame within its
|
||||
// tab). We need to ask the top frame what its scroll position is.
|
||||
return chrome.tabs.sendMessage(sender.tab.id, {name: "getScrollPosition"}, response => {
|
||||
return this.saveMark(Object.assign(markInfo,
|
||||
{scrollX: response.scrollX, scrollY: response.scrollY}));
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
saveMark(markInfo) {
|
||||
const item = {};
|
||||
item[this.getLocationKey(markInfo.markName)] = markInfo;
|
||||
return Settings.storage.set(item);
|
||||
},
|
||||
|
||||
// Goto a global mark. We try to find the original tab. If we can't find that, then we try to find another
|
||||
// tab with the original URL, and use that. And if we can't find such an existing tab, then we create a new
|
||||
// one. Whichever of those we do, we then set the scroll position to the original scroll position.
|
||||
goto(req, sender) {
|
||||
chrome.storage.local.get("vimiumSecret", items => {
|
||||
const {
|
||||
vimiumSecret
|
||||
} = items;
|
||||
const key = this.getLocationKey(req.markName);
|
||||
return Settings.storage.get(key, items => {
|
||||
const markInfo = items[key];
|
||||
if (markInfo.vimiumSecret !== vimiumSecret) {
|
||||
// This is a different Vimium instantiation, so markInfo.tabId is definitely out of date.
|
||||
return this.focusOrLaunch(markInfo, req);
|
||||
} else {
|
||||
// Check whether markInfo.tabId still exists. According to
|
||||
// https://developer.chrome.com/extensions/tabs, tab Ids are unqiue within a Chrome session. So, if
|
||||
// we find a match, we can use it.
|
||||
return chrome.tabs.get(markInfo.tabId, tab => {
|
||||
if (!chrome.runtime.lastError && tab && tab.url && (markInfo.url === this.getBaseUrl(tab.url))) {
|
||||
// The original tab still exists.
|
||||
return this.gotoPositionInTab(markInfo);
|
||||
} else {
|
||||
// The original tab no longer exists.
|
||||
return this.focusOrLaunch(markInfo, req);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Focus an existing tab and scroll to the given position within it.
|
||||
gotoPositionInTab({ tabId, scrollX, scrollY }) {
|
||||
chrome.tabs.update(tabId, { active: true }, (tab) => {
|
||||
chrome.windows.update(tab.windowId, { focused: true });
|
||||
chrome.tabs.sendMessage(tabId, {name: "setScrollPosition", scrollX, scrollY});
|
||||
});
|
||||
},
|
||||
|
||||
// The tab we're trying to find no longer exists. We either find another tab with a matching URL and use it,
|
||||
// or we create a new tab.
|
||||
focusOrLaunch(markInfo, req) {
|
||||
// If we're not going to be scrolling to a particular position in the tab, then we choose all tabs with a
|
||||
// matching URL prefix. Otherwise, we require an exact match (because it doesn't make sense to scroll
|
||||
// unless there's an exact URL match).
|
||||
const query = markInfo.scrollX === markInfo.scrollY && markInfo.scrollY === 0 ? `${markInfo.url}*` : markInfo.url;
|
||||
return chrome.tabs.query({ url: query }, tabs => {
|
||||
if (tabs.length > 0) {
|
||||
// We have at least one matching tab. Pick one and go to it.
|
||||
return this.pickTab(tabs, tab => {
|
||||
return this.gotoPositionInTab(Object.assign(markInfo, {tabId: tab.id}));
|
||||
});
|
||||
} else {
|
||||
// There is no existing matching tab, we'll have to create one.
|
||||
return TabOperations.openUrlInNewTab(Object.assign(req, {url: this.getBaseUrl(markInfo.url)}), tab => {
|
||||
// Note. tabLoadedHandlers is defined in "main.js". The handler below will be called when the tab
|
||||
// is loaded, its DOM is ready and it registers with the background page.
|
||||
return tabLoadedHandlers[tab.id] =
|
||||
() => this.gotoPositionInTab(Object.assign(markInfo, {tabId: tab.id}));
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Given a list of tabs candidate tabs, pick one. Prefer tabs in the current window and tabs with shorter
|
||||
// (matching) URLs.
|
||||
pickTab(tabs, callback) {
|
||||
const tabPicker = function({ id }) {
|
||||
// Prefer tabs in the current window, if there are any.
|
||||
let tab;
|
||||
const tabsInWindow = tabs.filter(tab => tab.windowId === id);
|
||||
if (tabsInWindow.length > 0) { tabs = tabsInWindow; }
|
||||
// If more than one tab remains and the current tab is still a candidate, then don't pick the current
|
||||
// tab (because jumping to it does nothing).
|
||||
if (tabs.length > 1)
|
||||
tabs = tabs.filter(t => !t.active)
|
||||
|
||||
// Prefer shorter URLs.
|
||||
tabs.sort((a, b) => a.url.length - b.url.length);
|
||||
return callback(tabs[0]);
|
||||
};
|
||||
if (chrome.windows != null)
|
||||
return chrome.windows.getCurrent(tabPicker);
|
||||
else
|
||||
return tabPicker({id: undefined});
|
||||
}
|
||||
};
|
||||
|
||||
global.Marks = Marks;
|
||||
Loading…
Add table
Add a link
Reference in a new issue