📝 Added entire config directory for backup purposes

This commit is contained in:
z3rOR0ne 2022-11-03 00:09:22 -07:00
parent 4b38e59bb6
commit 9b946e2d14
11091 changed files with 1440953 additions and 0 deletions

View file

@ -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: {
'"': "&quots;",
'&': "&amp;",
"'": "&apos;",
"<": "&lt;",
">": "&gt;"
},
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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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});

View file

@ -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;

View file

@ -0,0 +1,6 @@
/* Chrome file:// URLs set draggable=true for links to files (CSS selector .icon.file). This automatically
* sets -webkit-user-select: none, which disables selecting the file names and so prevents Vimium's search
* from working as expected. Here, we reset the value back to default. */
.icon.file {
-webkit-user-select: auto !important;
}

View file

@ -0,0 +1,231 @@
//
// A heads-up-display (HUD) for showing Vimium page operations.
// Note: you cannot interact with the HUD until document.body is available.
//
const HUD = {
tween: null,
hudUI: null,
findMode: null,
abandon() {
if (this.hudUI)
this.hudUI.hide(false);
},
pasteListener: null, // Set by @pasteFromClipboard to handle the value returned by pasteResponse
// This HUD is styled to precisely mimick the chrome HUD on Mac. Use the "has_popup_and_link_hud.html"
// test harness to tweak these styles to match Chrome's. One limitation of our HUD display is that
// it doesn't sit on top of horizontal scrollbars like Chrome's HUD does.
init(focusable) {
if (focusable == null)
focusable = true;
if (this.hudUI == null) {
this.hudUI = new UIComponent("pages/hud.html", "vimiumHUDFrame", ({data}) => {
if (this[data.name])
return this[data.name](data);
});
}
// this[data.name]? data
if (this.tween == null)
this.tween = new Tween("iframe.vimiumHUDFrame.vimiumUIComponentVisible", this.hudUI.shadowDOM);
if (focusable) {
this.hudUI.toggleIframeElementClasses("vimiumNonClickable", "vimiumClickable");
// Note(gdh1995): Chrome 74 only acknowledges text selection when a frame has been visible. See more in #3277.
// Note(mrmr1993): Show the HUD frame, so Firefox will actually perform the paste.
this.hudUI.toggleIframeElementClasses("vimiumUIComponentHidden", "vimiumUIComponentVisible");
// Force the re-computation of styles, so Chrome sends a visibility change message to the child frame.
// See https://github.com/philc/vimium/pull/3277#issuecomment-487363284
getComputedStyle(this.hudUI.iframeElement).display;
} else {
this.hudUI.toggleIframeElementClasses("vimiumClickable", "vimiumNonClickable");
}
},
showForDuration(text, duration) {
this.show(text);
this._showForDurationTimerId = setTimeout((() => this.hide()), duration);
},
show(text) {
DomUtils.documentComplete(() => {
// @hudUI.activate will take charge of making it visible
this.init(false);
clearTimeout(this._showForDurationTimerId);
this.hudUI.activate({name: "show", text});
this.tween.fade(1.0, 150);
});
},
showFindMode(findMode = null) {
this.findMode = findMode;
DomUtils.documentComplete(() => {
this.init();
this.hudUI.activate({name: "showFindMode"});
this.tween.fade(1.0, 150);
});
},
search(data) {
// NOTE(mrmr1993): On Firefox, window.find moves the window focus away from the HUD. We use postFindFocus
// to put it back, so the user can continue typing.
this.findMode.findInPlace(data.query, {"postFindFocus": this.hudUI.iframeElement.contentWindow});
// Show the number of matches in the HUD UI.
const matchCount = FindMode.query.parsedQuery.length > 0 ? FindMode.query.matchCount : 0;
const showMatchText = FindMode.query.rawQuery.length > 0;
this.hudUI.postMessage({name: "updateMatchesCount", matchCount, showMatchText});
},
// Hide the HUD.
// If :immediate is falsy, then the HUD is faded out smoothly (otherwise it is hidden immediately).
// If :updateIndicator is truthy, then we also refresh the mode indicator. The only time we don't update the
// mode indicator, is when hide() is called for the mode indicator itself.
hide(immediate, updateIndicator) {
if (immediate == null)
immediate = false;
if (updateIndicator == null)
updateIndicator = true;
if ((this.hudUI != null) && (this.tween != null)) {
clearTimeout(this._showForDurationTimerId);
this.tween.stop();
if (immediate) {
if (updateIndicator)
Mode.setIndicator();
else
this.hudUI.hide();
} else {
this.tween.fade(0, 150, () => this.hide(true, updateIndicator));
}
}
},
// These parameters describe the reason find mode is exiting, and come from the HUD UI component.
hideFindMode({exitEventIsEnter, exitEventIsEscape}) {
let postExit;
this.findMode.checkReturnToViewPort();
// An element won't receive a focus event if the search landed on it while we were in the HUD iframe. To
// end up with the correct modes active, we create a focus/blur event manually after refocusing this
// window.
window.focus();
const focusNode = DomUtils.getSelectionFocusElement();
if (document.activeElement != null)
document.activeElement.blur();
if (focusNode && focusNode.focus)
focusNode.focus();
if (exitEventIsEnter) {
FindMode.handleEnter();
if (FindMode.query.hasResults)
postExit = () => newPostFindMode();
} else if (exitEventIsEscape) {
// We don't want FindMode to handle the click events that FindMode.handleEscape can generate, so we
// wait until the mode is closed before running it.
postExit = FindMode.handleEscape;
}
this.findMode.exit();
if (postExit)
postExit();
},
// These commands manage copying and pasting from the clipboard in the HUD frame.
// NOTE(mrmr1993): We need this to copy and paste on Firefox:
// * an element can't be focused in the background page, so copying/pasting doesn't work
// * we don't want to disrupt the focus in the page, in case the page is listening for focus/blur events.
// * the HUD shouldn't be active for this frame while any of the copy/paste commands are running.
copyToClipboard(text) {
DomUtils.documentComplete(() => {
this.init();
this.hudUI.postMessage({name: "copyToClipboard", data: text});
});
},
pasteFromClipboard(pasteListener) {
this.pasteListener = pasteListener;
DomUtils.documentComplete(() => {
this.init();
this.tween.fade(0, 0);
this.hudUI.postMessage({name: "pasteFromClipboard"});
});
},
pasteResponse({data}) {
// Hide the HUD frame again.
this.hudUI.toggleIframeElementClasses("vimiumUIComponentVisible", "vimiumUIComponentHidden");
this.unfocusIfFocused();
this.pasteListener(data);
},
unfocusIfFocused() {
// On Firefox, if an <iframe> disappears when it's focused, then it will keep "focused",
// which means keyboard events will always be dispatched to the HUD iframe
if (this.hudUI && this.hudUI.showing) {
this.hudUI.iframeElement.blur();
window.focus();
}
}
};
class Tween {
constructor(cssSelector, insertionPoint) {
this.opacity = 0;
this.intervalId = -1;
this.styleElement = null;
this.cssSelector = cssSelector;
if (insertionPoint == null) { insertionPoint = document.documentElement; }
this.styleElement = DomUtils.createElement("style");
if (!this.styleElement.style) {
// We're in an XML document, so we shouldn't inject any elements. See the comment in UIComponent.
Tween.prototype.fade = (Tween.prototype.stop = (Tween.prototype.updateStyle = function() {}));
return;
}
this.styleElement.type = "text/css";
this.styleElement.innerHTML = "";
insertionPoint.appendChild(this.styleElement);
}
fade(toAlpha, duration, onComplete) {
clearInterval(this.intervalId);
const startTime = (new Date()).getTime();
const fromAlpha = this.opacity;
const alphaStep = toAlpha - fromAlpha;
const performStep = () => {
const elapsed = (new Date()).getTime() - startTime;
if (elapsed >= duration) {
clearInterval(this.intervalId);
this.updateStyle(toAlpha);
if (onComplete)
onComplete();
} else {
const value = ((elapsed / duration) * alphaStep) + fromAlpha;
this.updateStyle(value);
}
};
this.updateStyle(this.opacity);
this.intervalId = setInterval(performStep, 50);
}
stop() {
clearInterval(this.intervalId);
}
updateStyle(opacity) {
this.opacity = opacity;
this.styleElement.innerHTML = `\
${this.cssSelector} {
opacity: ${this.opacity};
}\
`;
}
}
global.HUD = HUD;

View file

@ -0,0 +1,48 @@
// The code in `injectedCode()`, below, is injected into the page's own execution context.
//
// This is based on method 2b here: http://stackoverflow.com/a/9517879, and
// @mrmr1993's #1167.
const injectedCode = function() {
// Note the presence of "click" listeners installed with `addEventListener()` (for link hints).
const _addEventListener = EventTarget.prototype.addEventListener;
const _toString = Function.prototype.toString;
// Note some pages may override Element (see https://github.com/gdh1995/vimium-plus/issues/11)
const EL = typeof Element === "function" ? Element : HTMLElement;
const Anchor = HTMLAnchorElement;
const addEventListener = function(type, listener, useCapture) {
if ((type === "click") && this instanceof EL) {
if (!(this instanceof Anchor)) { // Just skip <a>.
try { this.setAttribute("_vimium-has-onclick-listener", ""); } catch (error) {}
}
}
return (_addEventListener != null ? _addEventListener.apply(this, arguments) : undefined);
};
var newToString = function() {
const real = (() => {
if (this === newToString) { return _toString; } else {
if (this === addEventListener) { _addEventListener; } else {}
return this;
}
})();
return _toString.apply(real, arguments);
};
EventTarget.prototype.addEventListener = addEventListener;
// Libraries like Angular/Zone and CKEditor check if element.addEventListener is native,
// so here we hook it to tell outsides it is exactly native.
// This idea is from https://github.com/angular/zone.js/pull/686,
// and see more discussions in https://github.com/ckeditor/ckeditor5-build-classic/issues/34.
return Function.prototype.toString = newToString;
};
// NOTE(smblott) Disabled pending resolution of #2997.
if (false) {
const script = document.createElement("script");
script.textContent = `(${injectedCode.toString()})()`;
(document.head || document.documentElement).appendChild(script);
script.remove();
}

View file

@ -0,0 +1,125 @@
const Marks = {
previousPositionRegisters: [ "`", "'" ],
localRegisters: {},
currentRegistryEntry: null,
mode: null,
exit(continuation = null) {
if (this.mode != null)
this.mode.exit();
this.mode = null;
if (continuation)
return continuation(); // TODO(philc): Is this return necessary?
},
// This returns the key which is used for storing mark locations in localStorage.
getLocationKey(keyChar) {
return `vimiumMark|${window.location.href.split('#')[0]}|${keyChar}`;
},
getMarkString() {
return JSON.stringify({scrollX: window.scrollX, scrollY: window.scrollY, hash: window.location.hash});
},
setPreviousPosition() {
const markString = this.getMarkString();
for (const reg of this.previousPositionRegisters)
this.localRegisters[reg] = markString;
},
showMessage(message, keyChar) {
HUD.showForDuration(`${message} \"${keyChar}\".`, 1000);
},
// If <Shift> is depressed, then it's a global mark, otherwise it's a local mark. This is consistent
// vim's [A-Z] for global marks and [a-z] for local marks. However, it also admits other non-Latin
// characters. The exceptions are "`" and "'", which are always considered local marks.
// The "swap" command option inverts global and local marks.
isGlobalMark(event, keyChar) {
let shiftKey = event.shiftKey;
if (this.currentRegistryEntry.options.swap)
shiftKey = !shiftKey;
return shiftKey && !this.previousPositionRegisters.includes(keyChar);
},
activateCreateMode(count, {registryEntry}) {
this.currentRegistryEntry = registryEntry;
this.mode = new Mode()
this.mode.init({
name: "create-mark",
indicator: "Create mark...",
exitOnEscape: true,
suppressAllKeyboardEvents: true,
keydown: event => {
if (KeyboardUtils.isPrintable(event)) {
const keyChar = KeyboardUtils.getKeyChar(event);
this.exit(() => {
if (this.isGlobalMark(event, keyChar)) {
// We record the current scroll position, but only if this is the top frame within the tab.
// Otherwise, we'll fetch the scroll position of the top frame from the background page later.
let scrollX, scrollY;
if (DomUtils.isTopFrame())
[scrollX, scrollY] = [window.scrollX, window.scrollY];
chrome.runtime.sendMessage({
handler: 'createMark',
markName: keyChar,
scrollX,
scrollY
}, () => this.showMessage("Created global mark", keyChar));
} else {
localStorage[this.getLocationKey(keyChar)] = this.getMarkString();
this.showMessage("Created local mark", keyChar);
}
});
return handlerStack.suppressEvent;
}
}
});
},
activateGotoMode(count, {registryEntry}) {
this.currentRegistryEntry = registryEntry;
this.mode = new Mode()
this.mode.init({
name: "goto-mark",
indicator: "Go to mark...",
exitOnEscape: true,
suppressAllKeyboardEvents: true,
keydown: event => {
if (KeyboardUtils.isPrintable(event)) {
this.exit(() => {
const keyChar = KeyboardUtils.getKeyChar(event);
if (this.isGlobalMark(event, keyChar)) {
// This key must match @getLocationKey() in the back end.
const key = `vimiumGlobalMark|${keyChar}`;
Settings.storage.get(key, function(items) {
if (key in items) {
chrome.runtime.sendMessage({handler: 'gotoMark', markName: keyChar});
HUD.showForDuration(`Jumped to global mark '${keyChar}'`, 1000);
} else {
HUD.showForDuration(`Global mark not set '${keyChar}'`, 1000);
}
});
} else {
const markString = this.localRegisters[keyChar] != null ? this.localRegisters[keyChar] : localStorage[this.getLocationKey(keyChar)];
if (markString != null) {
this.setPreviousPosition();
const position = JSON.parse(markString);
if (position.hash && (position.scrollX === 0) && (position.scrollY === 0))
window.location.hash = position.hash;
else
window.scrollTo(position.scrollX, position.scrollY);
this.showMessage("Jumped to local mark", keyChar);
} else {
this.showMessage("Local mark not set", keyChar);
}
}
});
return handlerStack.suppressEvent;
}
}
});
}
};
global.Marks = Marks;

View file

@ -0,0 +1,299 @@
//
// A mode implements a number of keyboard (and possibly other) event handlers which are pushed onto the handler
// stack when the mode is activated, and popped off when it is deactivated. The Mode class constructor takes a
// single argument "options" which can define (amongst other things):
//
// name:
// A name for this mode.
//
// keydown:
// keypress:
// keyup:
// Key handlers. Optional: provide these as required. The default is to continue bubbling all key events.
//
// Further options are described in the constructor, below.
//
// Additional handlers associated with a mode can be added by using the push method. For example, if a mode
// responds to "focus" events, then push an additional handler:
// @push
// "focus": (event) => ....
// Such handlers are removed when the mode is deactivated.
//
// The following events can be handled:
// keydown, keypress, keyup, click, focus and blur
// Debug only.
let count = 0;
class Mode {
// This is a function rather than a constructor, becausae often subclasses need to reference `this` when
// setting up the options argument. `this` can't be referenced in subclasses prior to calling their
// superclass constructor.
init(options) {
// Constants; short, readable names for the return values expected by handlerStack.bubbleEvent, used here
// and by subclasses.
if (options == null)
options = {};
this.options = options;
this.continueBubbling = handlerStack.continueBubbling;
this.suppressEvent = handlerStack.suppressEvent;
this.passEventToPage = handlerStack.passEventToPage;
this.suppressPropagation = handlerStack.suppressPropagation;
this.restartBubbling = handlerStack.restartBubbling;
this.alwaysContinueBubbling = handlerStack.alwaysContinueBubbling;
this.alwaysSuppressPropagation = handlerStack.alwaysSuppressPropagation;
this.handlers = [];
this.exitHandlers = [];
this.modeIsActive = true;
this.modeIsExiting = false;
this.name = this.options.name || "anonymous";
this.count = ++count;
this.id = `${this.name}-${this.count}`;
this.log("activate:", this.id);
// If options.suppressAllKeyboardEvents is truthy, then all keyboard events are suppressed. This avoids
// the need for modes which suppress all keyboard events 1) to provide handlers for all of those events,
// or 2) to worry about event suppression and event-handler return values.
if (this.options.suppressAllKeyboardEvents) {
// TODO(philc): Make a let statement.
const downHanlder = this.options["keydown"];
this.options["keydown"] = (event) => this.alwaysSuppressPropagation(() => {
if (downHanlder)
return downHanlder(event);
});
const pressHandler = this.options["keypress"];
this.options["keypress"] = (event) => this.alwaysSuppressPropagation(() => {
if (pressHandler)
return pressHandler(event);
});
}
this.push({
keydown: this.options.keydown || null,
keypress: this.options.keypress || null,
keyup: this.options.keyup || null,
indicator: () => {
// Update the mode indicator. Setting @options.indicator to a string shows a mode indicator in the
// HUD. Setting @options.indicator to 'false' forces no mode indicator. If @options.indicator is
// undefined, then the request propagates to the next mode.
// The active indicator can also be changed with @setIndicator().
if (this.options.indicator != null) {
if (this.options.indicator) {
HUD.show(this.options.indicator);
} else {
HUD.hide(true, false);
}
return this.passEventToPage;
} else {
return this.continueBubbling;
}
}
});
// If @options.exitOnEscape is truthy, then the mode will exit when the escape key is pressed.
if (this.options.exitOnEscape) {
// Note. This handler ends up above the mode's own key handlers on the handler stack, so it takes
// priority.
this.push({
_name: `mode-${this.id}/exitOnEscape`,
"keydown": event => {
if (!KeyboardUtils.isEscape(event))
return this.continueBubbling;
this.exit(event, event.target);
return this.suppressEvent;
}
});
}
// If @options.exitOnBlur is truthy, then it should be an element. The mode will exit when that element
// loses the focus.
if (this.options.exitOnBlur) {
this.push({
_name: `mode-${this.id}/exitOnBlur`,
"blur": event => this.alwaysContinueBubbling(() => {
if (event.target === this.options.exitOnBlur)
return this.exit(event);
})
});
}
// If @options.exitOnClick is truthy, then the mode will exit on any click event.
if (this.options.exitOnClick) {
this.push({
_name: `mode-${this.id}/exitOnClick`,
"click": event => this.alwaysContinueBubbling(() => this.exit(event))
});
}
//If @options.exitOnFocus is truthy, then the mode will exit whenever a focusable element is activated.
if (this.options.exitOnFocus) {
this.push({
_name: `mode-${this.id}/exitOnFocus`,
"focus": event => this.alwaysContinueBubbling(() => {
if (DomUtils.isFocusable(event.target))
return this.exit(event);
})
});
}
// If @options.exitOnScroll is truthy, then the mode will exit on any scroll event.
if (this.options.exitOnScroll) {
this.push({
_name: `mode-${this.id}/exitOnScroll`,
"scroll": event => this.alwaysContinueBubbling(() => this.exit(event))
});
}
// Some modes are singletons: there may be at most one instance active at any time. A mode is a singleton
// if @options.singleton is set. The value of @options.singleton should be the key which is intended to be
// unique. New instances deactivate existing instances with the same key.
if (this.options.singleton) {
const singletons = Mode.singletons || (Mode.singletons = {});
const key = this.options.singleton;
this.onExit(() => delete singletons[key]);
if (singletons[key] != null)
singletons[key].exit();
singletons[key] = this;
}
// if @options.suppressTrailingKeyEvents is set, then -- on exit -- we suppress all key events until a
// subsquent (non-repeat) keydown or keypress. In particular, the intention is to catch keyup events for
// keys which we have handled, but which otherwise might trigger page actions (if the page is listening for
// keyup events).
if (this.options.suppressTrailingKeyEvents) {
this.onExit(function() {
const handler = function(event) {
if (event.repeat) {
return handlerStack.suppressEvent;
} else {
this.remove();
return handlerStack.continueBubbling;
}
};
return handlerStack.push({
name: "suppress-trailing-key-events",
keydown: handler,
keypress: handler
});
});
}
Mode.modes.push(this);
this.setIndicator();
this.logModes();
}
// End of Mode constructor.
setIndicator(indicator) {
if (indicator)
this.options.indicator = indicator;
return Mode.setIndicator();
}
static setIndicator() {
return handlerStack.bubbleEvent("indicator");
}
push(handlers) {
if (!handlers._name)
handlers._name = `mode-${this.id}`;
return this.handlers.push(handlerStack.push(handlers));
}
unshift(handlers) {
if (!handlers._name)
handlers._name = `mode-${this.id}`;
this.handlers.push(handlerStack.unshift(handlers));
}
onExit(handler) {
this.exitHandlers.push(handler);
}
exit(...args) {
if (this.modeIsExiting || !this.modeIsActive)
return;
this.log("deactivate:", this.id);
this.modeIsExiting = true;
for (let handler of this.exitHandlers)
// TODO(philc): Is this array.from necessary?
handler(...Array.from(args || []));
for (let handlerId of this.handlers)
handlerStack.remove(handlerId);
Mode.modes = Mode.modes.filter((mode) => mode !== this);
this.modeIsActive = false;
return this.setIndicator();
}
// Debugging routines.
logModes() {
if (Mode.debug) {
this.log("active modes (top to bottom):");
for (let mode of Mode.modes.slice().reverse())
this.log(" ", mode.id);
}
}
log(...args) {
if (Mode.debug)
console.log(...Array.from(args || []));
}
// For tests only.
static top() {
return this.modes[this.modes.length-1];
}
// For tests only.
static reset() {
for (let mode of this.modes)
mode.exit();
this.modes = [];
}
}
// If Mode.debug is true, then we generate a trace of modes being activated and deactivated on the console.
Mode.debug = false;
Mode.modes = [];
class SuppressAllKeyboardEvents extends Mode {
constructor(options) {
if (options == null)
options = {};
super();
const defaults = {
name: "suppressAllKeyboardEvents",
suppressAllKeyboardEvents: true
};
super.init(Object.assign(defaults, options));
}
}
class CacheAllKeydownEvents extends SuppressAllKeyboardEvents {
constructor(options) {
if (options == null)
options = {};
const keydownEvents = [];
const defaults = {
name: "cacheAllKeydownEvents",
keydown(event) { return keydownEvents.push(event); }
};
super(Object.assign(defaults, options));
this.keydownEvents = [];
}
replayKeydownEvents() {
return this.keydownEvents.map((event) => handlerStack.bubbleEvent("keydown", event));
}
}
Object.assign(global, {Mode, SuppressAllKeyboardEvents, CacheAllKeydownEvents});

View file

@ -0,0 +1,351 @@
// NOTE(smblott). Ultimately, all of the FindMode-related code should be moved here.
// This prevents unmapped printable characters from being passed through to underlying page; see #1415. Only
// used by PostFindMode, below.
class SuppressPrintable extends Mode {
constructor(options) {
super();
super.init(options);
const handler = event => KeyboardUtils.isPrintable(event) ? this.suppressEvent : this.continueBubbling;
const type = DomUtils.getSelectionType();
// We use unshift here, so we see events after normal mode, so we only see unmapped keys.
this.unshift({
_name: `mode-${this.id}/suppress-printable`,
keydown: handler,
keypress: handler,
keyup: event => {
// If the selection type has changed (usually, no longer "Range"), then the user is interacting with
// the input element, so we get out of the way. See discussion of option 5c from #1415.
if (DomUtils.getSelectionType() !== type)
return this.exit();
}
});
}
}
// When we use find, the selection/focus can land in a focusable/editable element. In this situation, special
// considerations apply. We implement three special cases:
// 1. Disable insert mode, because the user hasn't asked to enter insert mode. We do this by using
// InsertMode.suppressEvent.
// 2. Prevent unmapped printable keyboard events from propagating to the page; see #1415. We do this by
// inheriting from SuppressPrintable.
// 3. If the very-next keystroke is Escape, then drop immediately into insert mode.
//
var newPostFindMode = function() {
if (!document.activeElement || !DomUtils.isEditable(document.activeElement))
return;
return new PostFindMode();
}
class PostFindMode extends SuppressPrintable {
constructor() {
const element = document.activeElement;
super({
name: "post-find",
// PostFindMode shares a singleton with focusInput; each displaces the other.
singleton: "post-find-mode/focus-input",
exitOnBlur: element,
exitOnClick: true,
// Always truthy, so always continues bubbling.
keydown(event) { return InsertMode.suppressEvent(event); },
keypress(event) { return InsertMode.suppressEvent(event); },
keyup(event) { return InsertMode.suppressEvent(event); }
});
// If the very-next keydown is Escape, then exit immediately, thereby passing subsequent keys to the
// underlying insert-mode instance.
this.push({
_name: `mode-${this.id}/handle-escape`,
keydown: event => {
if (KeyboardUtils.isEscape(event)) {
this.exit();
return this.suppressEvent;
} else {
handlerStack.remove();
return this.continueBubbling;
}
}
});
}
}
class FindMode extends Mode {
constructor(options) {
super();
if (options == null)
options = {};
this.query = {
rawQuery: "",
parsedQuery: "",
matchCount: 0,
hasResults: false
};
// Save the selection, so findInPlace can restore it.
this.initialRange = getCurrentRange();
FindMode.query = {rawQuery: ""};
if (options.returnToViewport) {
this.scrollX = window.scrollX;
this.scrollY = window.scrollY;
}
super.init(Object.assign(options, {
name: "find",
indicator: false,
exitOnClick: true,
exitOnEscape: true,
// This prevents further Vimium commands launching before the find-mode HUD receives the focus.
// E.g. "/" followed quickly by "i" should not leave us in insert mode.
suppressAllKeyboardEvents: true
}));
HUD.showFindMode(this);
}
exit(event) {
HUD.unfocusIfFocused();
super.exit();
if (event)
FindMode.handleEscape();
}
restoreSelection() {
if (!this.initialRange)
return;
const range = this.initialRange;
const selection = getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
findInPlace(query, options) {
// If requested, restore the scroll position (so that failed searches leave the scroll position unchanged).
this.checkReturnToViewPort();
FindMode.updateQuery(query);
// Restore the selection. That way, we're always searching forward from the same place, so we find the
// right match as the user adds matching characters, or removes previously-matched characters. See #1434.
this.restoreSelection();
query = FindMode.query.isRegex ? FindMode.getNextQueryFromRegexMatches(0) : FindMode.query.parsedQuery;
FindMode.query.hasResults = FindMode.execute(query, options);
}
static updateQuery(query) {
let pattern;
this.query.rawQuery = query;
// the query can be treated differently (e.g. as a plain string versus regex depending on the presence of
// escape sequences. '\' is the escape character and needs to be escaped itself to be used as a normal
// character. here we grep for the relevant escape sequences.
this.query.isRegex = Settings.get('regexFindMode');
this.query.parsedQuery = this.query.rawQuery.replace(/(\\{1,2})([rRI]?)/g, (match, slashes, flag) => {
if ((flag === "") || (slashes.length !== 1))
return match;
switch (flag) {
case "r":
this.query.isRegex = true;
break;
case "R":
this.query.isRegex = false;
break;
}
return "";
});
// Implement smartcase.
this.query.ignoreCase = !Utils.hasUpperCase(this.query.parsedQuery);
const regexPattern = this.query.isRegex ?
this.query.parsedQuery :
Utils.escapeRegexSpecialCharacters(this.query.parsedQuery);
// If we are dealing with a regex, grep for all matches in the text, and then call window.find() on them
// sequentially so the browser handles the scrolling / text selection.
// If we are doing a basic plain string match, we still want to grep for matches of the string, so we can
// show a the number of results.
try {
pattern = new RegExp(regexPattern, `g${this.query.ignoreCase ? "i" : ""}`);
} catch (error) {
return; // If we catch a SyntaxError, assume the user is not done typing yet and return quietly.
}
// innerText will not return the text of hidden elements, and strip out tags while preserving newlines.
// NOTE(mrmr1993): innerText doesn't include the text contents of <input>s and <textarea>s. See #1118.
const text = document.body.innerText;
const regexMatches = text.match(pattern);
if (this.query.isRegex)
this.query.regexMatches = regexMatches;
if (this.query.isRegex)
this.query.activeRegexIndex = 0;
return this.query.matchCount = regexMatches != null ? regexMatches.length : null;
}
static getNextQueryFromRegexMatches(stepSize) {
// find()ing an empty query always returns false
if (!this.query.regexMatches)
return "";
const totalMatches = this.query.regexMatches.length;
this.query.activeRegexIndex += stepSize + totalMatches;
this.query.activeRegexIndex %= totalMatches;
return this.query.regexMatches[this.query.activeRegexIndex];
}
static getQuery(backwards) {
// check if the query has been changed by a script in another frame
const mostRecentQuery = FindModeHistory.getQuery();
if (mostRecentQuery !== this.query.rawQuery)
this.updateQuery(mostRecentQuery);
if (this.query.isRegex)
return this.getNextQueryFromRegexMatches(backwards ? -1 : 1);
else
return this.query.parsedQuery;
}
static saveQuery() { return FindModeHistory.saveQuery(this.query.rawQuery); }
// :options is an optional dict. valid parameters are 'caseSensitive' and 'backwards'.
static execute(query, options) {
let result = null;
options = Object.assign({
backwards: false,
caseSensitive: !this.query.ignoreCase,
colorSelection: true
}, options);
if (query == null)
query = FindMode.getQuery(options.backwards);
if (options.colorSelection) {
document.body.classList.add("vimiumFindMode");
// ignore the selectionchange event generated by find()
document.removeEventListener("selectionchange", this.restoreDefaultSelectionHighlight, true);
}
try {
result = window.find(query, options.caseSensitive, options.backwards, true, false, false, false);
} catch (error) {} // Failed searches throw on Firefox.
// window.find focuses the |window| that it is called on. This gives us an opportunity to (re-)focus
// another element/window, if that isn't the behaviour we want.
if (options.postFindFocus != null)
options.postFindFocus.focus();
if (options.colorSelection) {
setTimeout(
() => document.addEventListener("selectionchange", this.restoreDefaultSelectionHighlight, true)
, 0);
}
// We are either in normal mode ("n"), or find mode ("/"). We are not in insert mode. Nevertheless, if a
// previous find landed in an editable element, then that element may still be activated. In this case, we
// don't want to leave it behind (see #1412).
if (document.activeElement && DomUtils.isEditable(document.activeElement))
if (!DomUtils.isSelected(document.activeElement))
document.activeElement.blur();
return result;
}
// The user has found what they're looking for and is finished searching. We enter insert mode, if possible.
static handleEscape() {
document.body.classList.remove("vimiumFindMode");
// Removing the class does not re-color existing selections. we recreate the current selection so it reverts
// back to the default color.
const selection = window.getSelection();
if (!selection.isCollapsed) {
const range = window.getSelection().getRangeAt(0);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
}
return focusFoundLink() || selectFoundInputElement();
}
// Save the query so the user can do further searches with it.
static handleEnter() {
focusFoundLink();
document.body.classList.add("vimiumFindMode");
return FindMode.saveQuery();
}
static findNext(backwards) {
// Bail out if we don't have any query text.
const nextQuery = FindMode.getQuery(backwards);
if (!nextQuery) {
HUD.showForDuration("No query to find.", 1000);
return;
}
Marks.setPreviousPosition();
FindMode.query.hasResults = FindMode.execute(nextQuery, {backwards});
if (FindMode.query.hasResults) {
focusFoundLink();
return newPostFindMode();
} else {
return HUD.showForDuration(`No matches for '${FindMode.query.rawQuery}'`, 1000);
}
}
checkReturnToViewPort() {
if (this.options.returnToViewport)
window.scrollTo(this.scrollX, this.scrollY);
}
}
FindMode.restoreDefaultSelectionHighlight = forTrusted(() => document.body.classList.remove("vimiumFindMode"));
var getCurrentRange = function() {
const selection = getSelection();
if (DomUtils.getSelectionType(selection) === "None") {
const range = document.createRange();
range.setStart(document.body, 0);
range.setEnd(document.body, 0);
return range;
} else {
if (DomUtils.getSelectionType(selection) === "Range")
selection.collapseToStart();
return selection.getRangeAt(0);
}
};
const getLinkFromSelection = function() {
let node = window.getSelection().anchorNode;
while (node && (node !== document.body)) {
if (node.nodeName.toLowerCase() === "a")
return node;
node = node.parentNode;
}
return null;
};
var focusFoundLink = function() {
if (FindMode.query.hasResults) {
const link = getLinkFromSelection();
if (link)
link.focus();
}
};
var selectFoundInputElement = function() {
// Since the last focused element might not be the one currently pointed to by find (e.g. the current one
// might be disabled and therefore unable to receive focus), we use the approximate heuristic of checking
// that the last anchor node is an ancestor of our element.
const findModeAnchorNode = document.getSelection().anchorNode;
if (FindMode.query.hasResults && document.activeElement &&
DomUtils.isSelectable(document.activeElement) &&
DomUtils.isDOMDescendant(findModeAnchorNode, document.activeElement)) {
return DomUtils.simulateSelect(document.activeElement);
}
};
global.PostFindMode = PostFindMode;
global.FindMode = FindMode;

View file

@ -0,0 +1,115 @@
class InsertMode extends Mode {
constructor(options) {
super();
if (options == null)
options = {};
// There is one permanently-installed instance of InsertMode. It tracks focus changes and
// activates/deactivates itself (by setting @insertModeLock) accordingly.
this.permanent = options.permanent;
// If truthy, then we were activated by the user (with "i").
this.global = options.global;
const handleKeyEvent = event => {
if (!this.isActive(event))
return this.continueBubbling;
// See comment here: https://github.com/philc/vimium/commit/48c169bd5a61685bb4e67b1e76c939dbf360a658.
const activeElement = this.getActiveElement();
if ((activeElement === document.body) && activeElement.isContentEditable)
return this.passEventToPage;
// Check for a pass-next-key key.
const keyString = KeyboardUtils.getKeyCharString(event);
if (Settings.get("passNextKeyKeys").includes(keyString)) {
new PassNextKeyMode();
} else if ((event.type === 'keydown') && KeyboardUtils.isEscape(event)) {
if (DomUtils.isFocusable(activeElement))
activeElement.blur();
if (!this.permanent)
this.exit();
} else {
return this.passEventToPage;
}
return this.suppressEvent;
};
const defaults = {
name: "insert",
indicator: !this.permanent && !Settings.get("hideHud") ? "Insert mode" : null,
keypress: handleKeyEvent,
keydown: handleKeyEvent
};
super.init(Object.assign(defaults, options));
// Only for tests. This gives us a hook to test the status of the permanently-installed instance.
if (this.permanent)
InsertMode.permanentInstance = this;
}
isActive(event) {
if (event === InsertMode.suppressedEvent)
return false;
if (this.global)
return true;
return DomUtils.isFocusable(this.getActiveElement());
}
getActiveElement() {
let activeElement = document.activeElement;
while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement)
activeElement = activeElement.shadowRoot.activeElement;
return activeElement;
}
static suppressEvent(event) { return this.suppressedEvent = event; }
}
// This allows PostFindMode to suppress the permanently-installed InsertMode instance.
InsertMode.suppressedEvent = null;
// This implements the pasNexKey command.
class PassNextKeyMode extends Mode {
constructor(count) {
if (count == null)
count = 1;
super();
let seenKeyDown = false;
let keyDownCount = 0;
super.init({
name: "pass-next-key",
indicator: "Pass next key.",
// We exit on blur because, once we lose the focus, we can no longer track key events.
exitOnBlur: window,
keypress: () => {
return this.passEventToPage;
},
keydown: () => {
seenKeyDown = true;
keyDownCount += 1;
return this.passEventToPage;
},
keyup: () => {
if (seenKeyDown) {
if (!(--keyDownCount > 0)) {
if (!(--count > 0)) {
this.exit();
}
}
}
return this.passEventToPage;
}
});
}
}
global.InsertMode = InsertMode;
global.PassNextKeyMode = PassNextKeyMode;

View file

@ -0,0 +1,132 @@
// Example key mapping (@keyMapping):
// i:
// command: "enterInsertMode", ... # This is a registryEntry object (as too are the other commands).
// g:
// g:
// command: "scrollToTop", ...
// t:
// command: "nextTab", ...
//
// This key-mapping structure is generated by Commands.generateKeyStateMapping() and may be arbitrarily deep.
// Observe that @keyMapping["g"] is itself also a valid key mapping. At any point, the key state (@keyState)
// consists of a (non-empty) list of such mappings.
class KeyHandlerMode extends Mode {
setKeyMapping(keyMapping) { this.keyMapping = keyMapping; this.reset(); }
setPassKeys(passKeys) { this.passKeys = passKeys; this.reset(); }
// Only for tests.
setCommandHandler(commandHandler) {
this.commandHandler = commandHandler;
}
// Reset the key state, optionally retaining the count provided.
reset(countPrefix) {
if (countPrefix == null) { countPrefix = 0; }
this.countPrefix = countPrefix;
this.keyState = [this.keyMapping];
}
init(options) {
const args = Object.assign(options, {keydown: this.onKeydown.bind(this)});
super.init(args);
this.commandHandler = options.commandHandler || (function() {});
this.setKeyMapping(options.keyMapping || {});
if (options.exitOnEscape) {
// If we're part way through a command's key sequence, then a first Escape should reset the key state,
// and only a second Escape should actually exit this mode.
this.push({
_name: "key-handler-escape-listener",
keydown: event => {
if (KeyboardUtils.isEscape(event) && !this.isInResetState()) {
this.reset();
return this.suppressEvent;
} else {
return this.continueBubbling;
}
}
});
}
}
onKeydown(event) {
const keyChar = KeyboardUtils.getKeyCharString(event);
const isEscape = KeyboardUtils.isEscape(event);
if (isEscape && ((this.countPrefix !== 0) || (this.keyState.length !== 1))) {
return DomUtils.consumeKeyup(event, () => this.reset());
// If the help dialog loses the focus, then Escape should hide it; see point 2 in #2045.
} else if (isEscape && HelpDialog && HelpDialog.isShowing()) {
HelpDialog.toggle();
return this.suppressEvent;
} else if (isEscape) {
return this.continueBubbling;
} else if (this.isMappedKey(keyChar)) {
this.handleKeyChar(keyChar);
return this.suppressEvent;
} else if (this.isCountKey(keyChar)) {
const digit = parseInt(keyChar);
this.reset(this.keyState.length === 1 ? (this.countPrefix * 10) + digit : digit);
return this.suppressEvent;
} else {
if (keyChar) { this.reset(); }
return this.continueBubbling;
}
}
// This tests whether there is a mapping of keyChar in the current key state (and accounts for pass keys).
isMappedKey(keyChar) {
// TODO(philc): tweak the generated js.
return ((this.keyState.filter((mapping) => keyChar in mapping))[0] != null) && !this.isPassKey(keyChar);
}
// This tests whether keyChar is a digit (and accounts for pass keys).
isCountKey(keyChar) {
return keyChar
&& ((this.countPrefix > 0 ? '0' : '1') <= keyChar && keyChar <= '9')
&& !this.isPassKey(keyChar);
}
// Keystrokes are *never* considered pass keys if the user has begun entering a command. So, for example, if
// 't' is a passKey, then the "t"-s of 'gt' and '99t' are neverthless handled as regular keys.
isPassKey(keyChar) {
// Find all *continuation* mappings for keyChar in the current key state (i.e. not the full key mapping).
const mappings = (this.keyState.filter((mapping) => keyChar in mapping && (mapping !== this.keyMapping)));
// If there are no continuation mappings, and there's no count prefix, and keyChar is a pass key, then
// it's a pass key.
return mappings.length == 0
&& this.countPrefix == 0
&& this.passKeys
&& this.passKeys.includes(keyChar);
}
isInResetState() {
return (this.countPrefix === 0) && (this.keyState.length === 1);
}
handleKeyChar(keyChar) {
bgLog(`handle key ${keyChar} (${this.name})`);
// A count prefix applies only so long a keyChar is mapped in @keyState[0]; e.g. 7gj should be 1j.
if (!(keyChar in this.keyState[0]))
this.countPrefix = 0;
// Advance the key state. The new key state is the current mappings of keyChar, plus @keyMapping.
const state = (this.keyState.filter((mapping) => keyChar in mapping).map((mapping) => mapping[keyChar]));
state.push(this.keyMapping);
this.keyState = state;
if (this.keyState[0].command != null) {
const command = this.keyState[0];
const count = this.countPrefix > 0 ? this.countPrefix : 1;
bgLog(` invoke ${command.command} count=${count} `);
this.reset();
this.commandHandler({command, count});
if ((this.options.count != null) && (--this.options.count <= 0))
this.exit();
}
return this.suppressEvent;
}
}
global.KeyHandlerMode = KeyHandlerMode;

View file

@ -0,0 +1,461 @@
class NormalMode extends KeyHandlerMode {
init(options) {
if (options == null)
options = {};
const defaults = {
name: "normal",
indicator: false, // There is normally no mode indicator in normal mode.
commandHandler: this.commandHandler.bind(this)
};
super.init(Object.assign(defaults, options));
chrome.storage.local.get("normalModeKeyStateMapping",
(items) => this.setKeyMapping(items.normalModeKeyStateMapping));
chrome.storage.onChanged.addListener((changes, area) => {
if (area === "local" && changes.normalModeKeyStateMapping && changes.normalModeKeyStateMapping.newValue)
this.setKeyMapping(changes.normalModeKeyStateMapping.newValue);
});
}
commandHandler({command: registryEntry, count}) {
count *= registryEntry.options.count != null ? registryEntry.options.count : 1;
if (registryEntry.noRepeat)
count = 1;
if ((registryEntry.repeatLimit != null) && (registryEntry.repeatLimit < count)) {
const result = confirm(`You have asked Vimium to perform ${count} repetitions of the ` +
`command: ${registryEntry.description}.\n Are you sure you want to continue?`);
if (!result)
return;
}
if (registryEntry.topFrame) {
// We never return to a UI-component frame (e.g. the help dialog), it might have lost the focus.
const sourceFrameId = window.isVimiumUIComponent ? 0 : frameId;
chrome.runtime.sendMessage({
handler: "sendMessageToFrames", message: {name: "runInTopFrame", sourceFrameId, registryEntry}});
} else if (registryEntry.background) {
chrome.runtime.sendMessage({handler: "runBackgroundCommand", registryEntry, count});
} else {
NormalModeCommands[registryEntry.command](count, {registryEntry});
}
}
}
const enterNormalMode = function(count) {
const mode = new NormalMode();
mode.init({
indicator: "Normal mode (pass keys disabled)",
exitOnEscape: true,
singleton: "enterNormalMode",
count});
return mode;
};
var NormalModeCommands = {
// Scrolling.
scrollToBottom() {
Marks.setPreviousPosition();
Scroller.scrollTo("y", "max");
},
scrollToTop(count) {
Marks.setPreviousPosition();
Scroller.scrollTo("y", (count - 1) * Settings.get("scrollStepSize"));
},
scrollToLeft() { Scroller.scrollTo("x", 0); },
scrollToRight() { Scroller.scrollTo("x", "max"); },
scrollUp(count) { Scroller.scrollBy("y", -1 * Settings.get("scrollStepSize") * count); },
scrollDown(count) { Scroller.scrollBy("y", Settings.get("scrollStepSize") * count); },
scrollPageUp(count) { Scroller.scrollBy("y", "viewSize", (-1/2) * count); },
scrollPageDown(count) { Scroller.scrollBy("y", "viewSize", (1/2) * count); },
scrollFullPageUp(count) { Scroller.scrollBy("y", "viewSize", -1 * count); },
scrollFullPageDown(count) { Scroller.scrollBy("y", "viewSize", 1 * count); },
scrollLeft(count) { Scroller.scrollBy("x", -1 * Settings.get("scrollStepSize") * count); },
scrollRight(count) { Scroller.scrollBy("x", Settings.get("scrollStepSize") * count); },
// Tab navigation: back, forward.
goBack(count) { history.go(-count); },
goForward(count) { history.go(count); },
// Url manipulation.
goUp(count) {
let url = window.location.href;
if (url.endsWith("/"))
url = url.substring(0, url.length - 1);
let urlsplit = url.split("/");
// make sure we haven't hit the base domain yet
if (urlsplit.length > 3) {
urlsplit = urlsplit.slice(0, Math.max(3, urlsplit.length - count));
window.location.href = urlsplit.join('/');
}
},
goToRoot() {
window.location.href = window.location.origin;
},
toggleViewSource() {
chrome.runtime.sendMessage({ handler: "getCurrentTabUrl" }, function(url) {
if (url.substr(0, 12) === "view-source:") {
url = url.substr(12, url.length - 12);
} else {
url = "view-source:" + url;
}
return chrome.runtime.sendMessage({handler: "openUrlInNewTab", url});
});
},
copyCurrentUrl() {
chrome.runtime.sendMessage({ handler: "getCurrentTabUrl" }, function(url) {
HUD.copyToClipboard(url);
if (28 < url.length)
url = url.slice(0, 26) + "....";
HUD.showForDuration(`Yanked ${url}`, 2000);
});
},
openCopiedUrlInNewTab(count) {
HUD.pasteFromClipboard(url => chrome.runtime.sendMessage({ handler: "openUrlInNewTab", url, count }));
},
openCopiedUrlInCurrentTab() {
HUD.pasteFromClipboard(url => chrome.runtime.sendMessage({ handler: "openUrlInCurrentTab", url }));
},
// Mode changes.
enterInsertMode() {
// If a focusable element receives the focus, then we exit and leave the permanently-installed insert-mode
// instance to take over.
return new InsertMode({global: true, exitOnFocus: true});
},
enterVisualMode() {
const mode = new VisualMode();
mode.init({userLaunchedMode: true});
return mode;
},
enterVisualLineMode() {
const mode = new VisualLineMode();
mode.init({userLaunchedMode: true});
return mode;
},
enterFindMode() {
Marks.setPreviousPosition();
return new FindMode();
},
// Find.
performFind(count) {
for (let i = 0, end = count; i < end; i++)
FindMode.findNext(false);
},
performBackwardsFind(count) {
for (let i = 0, end = count; i < end; i++)
FindMode.findNext(true);
},
// Misc.
mainFrame() { return focusThisFrame({highlight: true, forceFocusThisFrame: true}); },
showHelp(sourceFrameId) { return HelpDialog.toggle({sourceFrameId, showAllCommandDetails: false}); },
passNextKey(count, options) {
// TODO(philc): OK to remove return statement?
if (options.registryEntry.options.normal) {
return enterNormalMode(count);
} else {
return new PassNextKeyMode(count);
}
},
goPrevious() {
const previousPatterns = Settings.get("previousPatterns") || "";
const previousStrings = previousPatterns.split(",").filter(s => s.trim().length);
return findAndFollowRel("prev") || findAndFollowLink(previousStrings);
},
goNext() {
const nextPatterns = Settings.get("nextPatterns") || "";
const nextStrings = nextPatterns.split(",").filter( s => s.trim().length);
return findAndFollowRel("next") || findAndFollowLink(nextStrings);
},
focusInput(count) {
// Focus the first input element on the page, and create overlays to highlight all the input elements, with
// the currently-focused element highlighted specially. Tabbing will shift focus to the next input element.
// Pressing any other key will remove the overlays and the special tab behavior.
let element, selectedInputIndex;
const resultSet = DomUtils.evaluateXPath(textInputXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);
const visibleInputs = [];
for (let i = 0, end = resultSet.snapshotLength; i < end; i++) {
element = resultSet.snapshotItem(i);
if (!DomUtils.getVisibleClientRect(element, true))
continue;
visibleInputs.push({ element, index: i, rect: Rect.copy(element.getBoundingClientRect()) });
}
visibleInputs.sort(function({element: element1, index: i1}, {element: element2, index: i2}) {
// Put elements with a lower positive tabIndex first, keeping elements in DOM order.
if (element1.tabIndex > 0) {
if (element2.tabIndex > 0) {
const tabDifference = element1.tabIndex - element2.tabIndex;
if (tabDifference !== 0) {
return tabDifference;
} else {
return i1 - i2;
}
} else {
return -1;
}
} else if (element2.tabIndex > 0) {
return 1;
} else {
return i1 - i2;
}
});
if (visibleInputs.length === 0) {
HUD.showForDuration("There are no inputs to focus.", 1000);
return;
}
// This is a hack to improve usability on the Vimium options page. We prime the recently-focused input
// to be the key-mappings input. Arguably, this is the input that the user is most likely to use.
const recentlyFocusedElement = lastFocusedInput();
if (count === 1) {
// As the starting index, we pick that of the most recently focused input element (or 0).
const elements = visibleInputs.map(visibleInput => visibleInput.element);
selectedInputIndex = Math.max(0, elements.indexOf(recentlyFocusedElement));
} else {
selectedInputIndex = Math.min(count, visibleInputs.length) - 1;
}
const hints = visibleInputs.map((tuple) => {
const hint = DomUtils.createElement("div");
hint.className = "vimiumReset internalVimiumInputHint vimiumInputHint";
// minus 1 for the border
hint.style.left = (tuple.rect.left - 1) + window.scrollX + "px";
hint.style.top = (tuple.rect.top - 1) + window.scrollY + "px";
hint.style.width = tuple.rect.width + "px";
hint.style.height = tuple.rect.height + "px";
return hint;
});
return new FocusSelector(hints, visibleInputs, selectedInputIndex);
}
};
if (typeof LinkHints !== 'undefined') {
Object.assign(NormalModeCommands, {
"LinkHints.activateMode": LinkHints.activateMode.bind(LinkHints),
"LinkHints.activateModeToOpenInNewTab": LinkHints.activateModeToOpenInNewTab.bind(LinkHints),
"LinkHints.activateModeToOpenInNewForegroundTab": LinkHints.activateModeToOpenInNewForegroundTab.bind(LinkHints),
"LinkHints.activateModeWithQueue": LinkHints.activateModeWithQueue.bind(LinkHints),
"LinkHints.activateModeToOpenIncognito": LinkHints.activateModeToOpenIncognito.bind(LinkHints),
"LinkHints.activateModeToDownloadLink": LinkHints.activateModeToDownloadLink.bind(LinkHints),
"LinkHints.activateModeToCopyLinkUrl": LinkHints.activateModeToCopyLinkUrl.bind(LinkHints)
});
}
if (typeof Vomnibar !== 'undefined') {
Object.assign(NormalModeCommands, {
"Vomnibar.activate": Vomnibar.activate.bind(Vomnibar),
"Vomnibar.activateInNewTab": Vomnibar.activateInNewTab.bind(Vomnibar),
"Vomnibar.activateTabSelection": Vomnibar.activateTabSelection.bind(Vomnibar),
"Vomnibar.activateBookmarks": Vomnibar.activateBookmarks.bind(Vomnibar),
"Vomnibar.activateBookmarksInNewTab": Vomnibar.activateBookmarksInNewTab.bind(Vomnibar),
"Vomnibar.activateEditUrl": Vomnibar.activateEditUrl.bind(Vomnibar),
"Vomnibar.activateEditUrlInNewTab": Vomnibar.activateEditUrlInNewTab.bind(Vomnibar)
});
}
if (typeof Marks !== 'undefined') {
Object.assign(NormalModeCommands, {
"Marks.activateCreateMode": Marks.activateCreateMode.bind(Marks),
"Marks.activateGotoMode": Marks.activateGotoMode.bind(Marks)
});
}
// The types in <input type="..."> that we consider for focusInput command. Right now this is recalculated in
// each content script. Alternatively we could calculate it once in the background page and use a request to
// fetch it each time.
// Should we include the HTML5 date pickers here?
// The corresponding XPath for such elements.
var textInputXPath = (function() {
const textInputTypes = ["text", "search", "email", "url", "number", "password", "date", "tel"];
const inputElements = ["input[" +
"(" + textInputTypes.map(type => '@type="' + type + '"').join(" or ") + "or not(@type))" +
" and not(@disabled or @readonly)]",
"textarea", "*[@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"];
if (typeof DomUtils !== 'undefined' && DomUtils !== null) {
return DomUtils.makeXPath(inputElements);
}
})();
// used by the findAndFollow* functions.
const followLink = function(linkElement) {
if (linkElement.nodeName.toLowerCase() === "link") {
return window.location.href = linkElement.href;
} else {
// if we can click on it, don't simply set location.href: some next/prev links are meant to trigger AJAX
// calls, like the 'more' button on GitHub's newsfeed.
linkElement.scrollIntoView();
return DomUtils.simulateClick(linkElement);
}
};
//
// Find and follow a link which matches any one of a list of strings. If there are multiple such links, they
// are prioritized for shortness, by their position in :linkStrings, how far down the page they are located,
// and finally by whether the match is exact. Practically speaking, this means we favor 'next page' over 'the
// next big thing', and 'more' over 'nextcompany', even if 'next' occurs before 'more' in :linkStrings.
//
var findAndFollowLink = function(linkStrings) {
let link, linkString;
const linksXPath = DomUtils.makeXPath(["a", "*[@onclick or @role='link' or contains(@class, 'button')]"]);
const links = DomUtils.evaluateXPath(linksXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);
let candidateLinks = [];
// at the end of this loop, candidateLinks will contain all visible links that match our patterns
// links lower in the page are more likely to be the ones we want, so we loop through the snapshot backwards
for (let i = links.snapshotLength - 1; i >= 0; i--) {
link = links.snapshotItem(i);
// ensure link is visible (we don't mind if it is scrolled offscreen)
const boundingClientRect = link.getBoundingClientRect();
if ((boundingClientRect.width === 0) || (boundingClientRect.height === 0))
continue;
const computedStyle = window.getComputedStyle(link, null);
if ((computedStyle.getPropertyValue("visibility") !== "visible") ||
(computedStyle.getPropertyValue("display") === "none"))
continue;
let linkMatches = false;
for (linkString of linkStrings) {
if ((link.innerText.toLowerCase().indexOf(linkString) !== -1) ||
(link.value && link.value.includes && link.value.includes(linkString))) {
linkMatches = true;
break;
}
}
if (!linkMatches)
continue;
candidateLinks.push(link);
}
if (candidateLinks.length == 0)
return;
for (link of candidateLinks)
link.wordCount = link.innerText.trim().split(/\s+/).length;
// We can use this trick to ensure that Array.sort is stable. We need this property to retain the reverse
// in-page order of the links.
candidateLinks.forEach((a, i) => a.originalIndex = i);
// favor shorter links, and ignore those that are more than one word longer than the shortest link
candidateLinks = candidateLinks
.sort(function(a, b) {
if (a.wordCount === b.wordCount)
return a.originalIndex - b.originalIndex;
else
return a.wordCount - b.wordCount;
})
.filter(a => a.wordCount <= (candidateLinks[0].wordCount + 1));
for (linkString of linkStrings) {
const exactWordRegex =
/\b/.test(linkString[0]) || /\b/.test(linkString[linkString.length - 1]) ?
new RegExp("\\b" + linkString + "\\b", "i")
: new RegExp(linkString, "i");
for (let candidateLink of candidateLinks) {
if (exactWordRegex.test(candidateLink.innerText) ||
(candidateLink.value && exactWordRegex.test(candidateLink.value))) {
followLink(candidateLink);
return true;
}
}
}
return false;
};
var findAndFollowRel = function(value) {
const relTags = ["link", "a", "area"];
for (let tag of relTags) {
const elements = document.getElementsByTagName(tag);
for (let element of Array.from(elements)) {
if (element.hasAttribute("rel") && (element.rel.toLowerCase() === value)) {
followLink(element);
return true;
}
}
}
};
class FocusSelector extends Mode {
constructor(hints, visibleInputs, selectedInputIndex) {
super(...arguments);
super.init({
name: "focus-selector",
exitOnClick: true,
keydown: event => {
if (event.key === "Tab") {
hints[selectedInputIndex].classList.remove('internalVimiumSelectedInputHint');
selectedInputIndex += hints.length + (event.shiftKey ? -1 : 1);
selectedInputIndex %= hints.length;
hints[selectedInputIndex].classList.add('internalVimiumSelectedInputHint');
DomUtils.simulateSelect(visibleInputs[selectedInputIndex].element);
return this.suppressEvent;
} else if (event.key !== "Shift") {
this.exit();
// Give the new mode the opportunity to handle the event.
return this.restartBubbling;
}
}});
this.hintContainingDiv = DomUtils.addElementList(hints, {
id: "vimiumInputMarkerContainer",
className: "vimiumReset"
});
DomUtils.simulateSelect(visibleInputs[selectedInputIndex].element);
if (visibleInputs.length === 1) {
this.exit();
return;
} else {
hints[selectedInputIndex].classList.add('internalVimiumSelectedInputHint');
}
}
exit() {
super.exit();
DomUtils.removeElement(this.hintContainingDiv);
if (document.activeElement && DomUtils.isEditable(document.activeElement)) {
return new InsertMode({
singleton: "post-find-mode/focus-input",
targetElement: document.activeElement,
indicator: false
});
}
}
}
global.NormalMode = NormalMode;
global.NormalModeCommands = NormalModeCommands;

View file

@ -0,0 +1,514 @@
// Symbolic names for some common strings.
const forward = "forward"; const backward = "backward"; const character = "character"; const word = "word"; const line = "line";
const sentence = "sentence"; const paragraph = "paragraph"; const vimword = "vimword"; const lineboundary= "lineboundary";
// This implements various selection movements.
class Movement {
constructor(alterMethod) {
this.alterMethod = alterMethod;
this.opposite = {forward: backward, backward: forward};
this.selection = window.getSelection();
}
// Return the character following (to the right of) the focus, and leave the selection unchanged, or return
// undefined.
getNextForwardCharacter() {
const beforeText = this.selection.toString();
if ((beforeText.length === 0) || (this.getDirection() === forward)) {
this.selection.modify("extend", forward, character);
const afterText = this.selection.toString();
if (beforeText !== afterText) {
this.selection.modify("extend", backward, character);
return afterText[afterText.length - 1];
}
} else {
return beforeText[0]; // The existing range selection is backwards.
}
}
// Test whether the character following the focus is a word character (and leave the selection unchanged).
nextCharacterIsWordCharacter() {
// This regexp matches "word" characters.
// From http://stackoverflow.com/questions/150033/regular-expression-to-match-non-english-characters.
if (!this.regexp) {
this.regexp = /[_0-9\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0527\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u08A0\u08A2-\u08AC\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0977\u0979-\u097F\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58\u0C59\u0C60\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D60\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19C1-\u19C7\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FCC\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA697\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA78E\uA790-\uA793\uA7A0-\uA7AA\uA7F8-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA80-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uABC0-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]/;
}
return this.regexp.test(this.getNextForwardCharacter())
}
// Run a movement. This is the core movement method, all movements happen here. For convenience, the
// following three argument forms are supported:
// runMovement("forward word")
// runMovement(["forward", "word"])
// runMovement("forward", "word")
//
// The granularities are word, "character", "line", "lineboundary", "sentence" and "paragraph". In addition,
// we implement the pseudo granularity "vimword", which implements vim-like word movement (e.g. "w").
//
runMovement(...args) {
// Normalize the various argument forms.
const [direction, granularity] =
(typeof(args[0]) === "string") && (args.length === 1) ?
args[0].trim().split(/\s+/) :
(args.length === 1 ? args[0] : args.slice(0, 2));
// Native word movements behave differently on Linux and Windows, see #1441. So we implement some of them
// character-by-character.
if ((granularity === vimword) && (direction === forward)) {
while (this.nextCharacterIsWordCharacter())
if (this.extendByOneCharacter(forward) === 0)
return;
while (this.getNextForwardCharacter() && !this.nextCharacterIsWordCharacter())
if (this.extendByOneCharacter(forward) === 0)
return;
} else if (granularity === vimword) {
this.selection.modify(this.alterMethod, backward, word);
}
// As above, we implement this character-by-character to get consistent behavior on Windows and Linux.
if ((granularity === word) && (direction === forward)) {
while (this.getNextForwardCharacter() && !this.nextCharacterIsWordCharacter())
if (this.extendByOneCharacter(forward) === 0)
return;
while (this.nextCharacterIsWordCharacter())
if (this.extendByOneCharacter(forward) === 0)
return;
} else {
return this.selection.modify(this.alterMethod, direction, granularity);
}
}
// Swap the anchor node/offset and the focus node/offset. This allows us to work with both ends of the
// selection, and implements "o" for visual mode.
reverseSelection() {
const direction = this.getDirection();
const element = document.activeElement;
if (element && DomUtils.isEditable(element) && !element.isContentEditable) {
// Note(smblott). This implementation is expensive if the selection is large. We only use it here
// because the normal method (below) does not work within text areas, etc.
const length = this.selection.toString().length;
this.collapseSelectionToFocus();
for (let i = 0, end = length; i < end; i++)
this.runMovement(this.opposite[direction], character);
} else {
// Normal method.
const original = this.selection.getRangeAt(0).cloneRange();
const range = original.cloneRange();
range.collapse(direction === backward);
this.setSelectionRange(range);
const which = direction === forward ? "start" : "end";
this.selection.extend(original[`${which}Container`], original[`${which}Offset`]);
}
}
// Try to extend the selection by one character in direction. Return positive, negative or 0, indicating
// whether the selection got bigger, or smaller, or is unchanged.
extendByOneCharacter(direction) {
const length = this.selection.toString().length;
this.selection.modify("extend", direction, character);
return this.selection.toString().length - length;
}
// Get the direction of the selection. The selection is "forward" if the focus is at or after the anchor,
// and "backward" otherwise.
// NOTE(smblott). This could be better, see: https://dom.spec.whatwg.org/#interface-range (however, that
// probably wouldn't work for inputs).
getDirection() {
// Try to move the selection forward or backward, check whether it got bigger or smaller (then restore it).
for (let direction of [ forward, backward ]) {
var change;
if (change = this.extendByOneCharacter(direction)) {
this.extendByOneCharacter(this.opposite[direction]);
if (change > 0)
return direction;
else
return this.opposite[direction];
}
}
return forward;
}
collapseSelectionToAnchor() {
if (this.selection.toString().length > 0)
return this.selection[this.getDirection() === backward ? "collapseToEnd" : "collapseToStart"]();
}
collapseSelectionToFocus() {
if (this.selection.toString().length > 0)
return this.selection[this.getDirection() === forward ? "collapseToEnd" : "collapseToStart"]();
}
setSelectionRange(range) {
this.selection.removeAllRanges();
// TODO(philc): Is this return needed?
return this.selection.addRange(range);
}
// For "aw", "as". We don't do "ap" (for paragraphs), because Chrome paragraph movements are weird.
selectLexicalEntity(entity, count) {
if (count == null)
count = 1;
this.collapseSelectionToFocus();
// This makes word movements a bit more vim-like.
if (entity === word)
this.runMovement([ forward, character ]);
this.runMovement([ backward, entity ]);
this.collapseSelectionToFocus();
for (let i = 0, end = count; i < end; i++)
this.runMovement([ forward, entity ]);
}
selectLine(count) {
// Even under caret mode, we still need an extended selection here.
this.alterMethod = "extend";
if (this.getDirection() === forward) { this.reverseSelection(); }
this.runMovement(backward, lineboundary);
this.reverseSelection();
for (let i = 1, end = count; i < end; i++) { this.runMovement(forward, line); }
this.runMovement(forward, lineboundary);
// Include the next character if that character is a newline.
if (this.getNextForwardCharacter() === "\n") { return this.runMovement(forward, character); }
}
// Scroll the focus into view.
scrollIntoView() {
if (DomUtils.getSelectionType(this.selection) !== "None") {
const elementWithFocus = DomUtils.getElementWithFocus(this.selection, this.getDirection() === backward);
if (elementWithFocus) { return Scroller.scrollIntoView(elementWithFocus); }
}
}
}
class VisualMode extends KeyHandlerMode {
init(options) {
let movement;
if (options == null)
options = {};
this.movement = new Movement(options.alterMethod != null ? options.alterMethod : "extend");
this.selection = this.movement.selection;
// Build the key mapping structure required by KeyHandlerMode. This only handles one- and two-key
// mappings.
const keyMapping = {};
for (let keys of Object.keys(this.movements || {})) {
movement = this.movements[keys];
if ("function" === typeof movement)
movement = movement.bind(this);
if (keys.length === 1) {
keyMapping[keys] = {command: movement};
} else { // keys.length == 2
if (keyMapping[keys[0]] == null)
keyMapping[keys[0]] = {};
Object.assign(keyMapping[keys[0]], {[keys[1]]: {command: movement}});
}
}
// Aliases and complex bindings.
Object.assign(keyMapping, {
"B": keyMapping.b,
"W": keyMapping.w,
"<c-e>": {
command(count) {
return Scroller.scrollBy("y", count * Settings.get("scrollStepSize"), 1, false);
}
},
"<c-y>": {
command(count) {
return Scroller.scrollBy("y", -count * Settings.get("scrollStepSize"), 1, false);
}
}
});
super.init(Object.assign(options, {
name: options.name != null ? options.name : "visual",
indicator: options.indicator != null ? options.indicator : "Visual mode",
singleton: "visual-mode-group", // Visual mode, visual-line mode and caret mode each displace each other.
exitOnEscape: true,
suppressAllKeyboardEvents: true,
keyMapping,
commandHandler: this.commandHandler.bind(this)
}));
// If there was a range selection when the user lanuched visual mode, then we retain the selection on exit.
this.shouldRetainSelectionOnExit = this.options.userLaunchedMode
&& (DomUtils.getSelectionType(this.selection) === "Range");
this.onExit((event = null) => {
// Retain any selection, regardless of how we exit.
if (this.shouldRetainSelectionOnExit) {
null;
// This mimics vim: when leaving visual mode via Escape, collapse to focus, otherwise collapse to anchor.
} else if (event && (event.type === "keydown") && KeyboardUtils.isEscape(event) && (this.name !== "caret")) {
this.movement.collapseSelectionToFocus();
} else {
this.movement.collapseSelectionToAnchor();
}
// Don't leave the user in insert mode just because they happen to have selected an input.
if (document.activeElement && DomUtils.isEditable(document.activeElement))
if ((event != null ? event.type : undefined) !== "click")
return document.activeElement.blur();
});
this.push({
_name: `${this.id}/enter/click`,
// Yank on <Enter>.
keypress: event => {
if (event.key === "Enter") {
if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) {
this.yank();
return this.suppressEvent;
}
}
return this.continueBubbling;
},
// Click in a focusable element exits.
click: event => this.alwaysContinueBubbling(() => {
if (DomUtils.isFocusable(event.target))
return this.exit(event);
})
});
// Establish or use the initial selection. If that's not possible, then enter caret mode.
if (this.name !== "caret") {
if (["Caret", "Range"].includes(DomUtils.getSelectionType(this.selection))) {
let selectionRect = this.selection.getRangeAt(0).getBoundingClientRect();
if (window.vimiumDomTestsAreRunning) {
// We're running the DOM tests, where getBoundingClientRect() isn't available.
if (!selectionRect)
selectionRect = {top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0};
}
selectionRect = Rect.intersect(selectionRect, Rect.create(0, 0, window.innerWidth, window.innerHeight));
if ((selectionRect.height >= 0) && (selectionRect.width >= 0)) {
// The selection is visible in the current viewport.
if (DomUtils.getSelectionType(this.selection) === "Caret")
// The caret is in the viewport. Make make it visible.
this.movement.extendByOneCharacter(forward) || this.movement.extendByOneCharacter(backward);
} else {
// The selection is outside of the viewport: clear it. We guess that the user has moved on, and is
// more likely to be interested in visible content.
this.selection.removeAllRanges();
}
}
if ((DomUtils.getSelectionType(this.selection) !== "Range") && (this.name !== "caret")) {
new CaretMode().init();
return HUD.showForDuration("No usable selection, entering caret mode...", 2500);
}
}
}
commandHandler({command: {command}, count}) {
switch (typeof command) {
case "string":
for (let i = 0, end = count; i < end; i++)
this.movement.runMovement(command);
break;
case "function":
command(count);
break;
}
return this.movement.scrollIntoView();
}
// find: (count, backwards) =>
find(count, backwards) {
const initialRange = this.selection.getRangeAt(0).cloneRange();
for (let i = 0, end = count; i < end; i++) {
const nextQuery = FindMode.getQuery(backwards);
if (!nextQuery) {
HUD.showForDuration("No query to find.", 1000);
return;
}
if (!FindMode.execute(nextQuery, {colorSelection: false, backwards})) {
this.movement.setSelectionRange(initialRange);
HUD.showForDuration(`No matches for '${FindMode.query.rawQuery}'`, 1000);
return;
}
}
// The find was successfull. If we're in caret mode, then we should now have a selection, so we can
// drop back into visual mode.
if ((this.name === "caret") && (this.selection.toString().length > 0)) {
const mode = new VisualMode();
mode.init();
return mode;
}
}
// Yank the selection; always exits; collapses the selection; set @yankedText and return it.
yank(args) {
if (args == null)
args = {};
this.yankedText = this.selection.toString();
this.exit();
HUD.copyToClipboard(this.yankedText);
let message = this.yankedText.replace(/\s+/g, " ");
if (15 < this.yankedText.length)
message = message.slice(0, 12) + "...";
const plural = this.yankedText.length === 1 ? "" : "s";
HUD.showForDuration(`Yanked ${this.yankedText.length} character${plural}: \"${message}\".`, 2500);
return this.yankedText;
}
}
// A movement can be either a string or a function.
VisualMode.prototype.movements = {
"l": "forward character",
"h": "backward character",
"j": "forward line",
"k": "backward line",
"e": "forward word",
"b": "backward word",
"w": "forward vimword",
")": "forward sentence",
"(": "backward sentence",
"}": "forward paragraph",
"{": "backward paragraph",
"0": "backward lineboundary",
"$": "forward lineboundary",
"G": "forward documentboundary",
"gg": "backward documentboundary",
"aw"(count) { return this.movement.selectLexicalEntity(word, count); },
"as"(count) { return this.movement.selectLexicalEntity(sentence, count); },
"n"(count) { return this.find(count, false); },
"N"(count) { return this.find(count, true); },
"/"() {
this.exit();
return new FindMode({returnToViewport: true}).onExit(() => new VisualMode());
},
"y"() { return this.yank(); },
"Y"(count) { this.movement.selectLine(count); return this.yank(); },
"p"() { return chrome.runtime.sendMessage({handler: "openUrlInCurrentTab", url: this.yank()}); },
"P"() { return chrome.runtime.sendMessage({handler: "openUrlInNewTab", url: this.yank()}); },
"v"() { return new VisualMode; },
"V"() { return new VisualLineMode; },
"c"() {
// If we're already in caret mode, or if the selection looks the same as it would in caret mode, then
// callapse to anchor (so that the caret-mode selection will seem unchanged). Otherwise, we're in visual
// mode and the user has moved the focus, so collapse to that.
if ((this.name === "caret") || (this.selection.toString().length <= 1))
this.movement.collapseSelectionToAnchor();
else
this.movement.collapseSelectionToFocus();
return new CaretMode;
},
"o"() { return this.movement.reverseSelection(); }
};
class VisualLineMode extends VisualMode {
init(options) {
if (options == null)
options = {};
super.init(Object.assign(options, {name: "visual/line", indicator: "Visual mode (line)"}));
return this.extendSelection();
}
commandHandler({command: {command}, count}) {
switch (typeof command) {
case "string":
for (let i = 0, end = count; i < end; i++) {
this.movement.runMovement(command);
// If the current selection
// * has only 1 line (the line that is selected when we # enter the visual line mode), and
// * its direction is different from the command,
// then the command will in effect unselect that line. In this case, we restore that line and
// reverse its direction, keeping that line selected.
if (this.selection.isCollapsed) {
this.extendSelection();
const [direction, granularity] = command.split(' ');
if ((this.movement.getDirection() !== direction) && (granularity === "line"))
this.movement.reverseSelection();
this.movement.runMovement(command);
}
}
break;
case "function":
command(count);
break;
}
this.movement.scrollIntoView();
if (this.modeIsActive)
return this.extendSelection();
}
extendSelection() {
const initialDirection = this.movement.getDirection();
// TODO(philc): Reformat this to be a plain loop rather than a closure.
return (() => {
const result = [];
for (let direction of [ initialDirection, this.movement.opposite[initialDirection] ]) {
this.movement.runMovement(direction, lineboundary);
result.push(this.movement.reverseSelection());
}
return result;
})();
}
}
class CaretMode extends VisualMode {
init(options) {
if (options == null)
options = {};
super.init(Object.assign(options, {name: "caret", indicator: "Caret mode", alterMethod: "move"}));
// Establish the initial caret.
switch (DomUtils.getSelectionType(this.selection)) {
case "None":
this.establishInitialSelectionAnchor();
if (DomUtils.getSelectionType(this.selection) === "None") {
this.exit();
HUD.showForDuration("Create a selection before entering visual mode.", 2500);
return;
}
break;
case "Range":
this.movement.collapseSelectionToAnchor();
break;
}
this.movement.extendByOneCharacter(forward);
return this.movement.scrollIntoView();
}
commandHandler(...args) {
this.movement.collapseSelectionToAnchor();
super.commandHandler(...(args || []));
if (this.modeIsActive)
return this.movement.extendByOneCharacter(forward);
}
// When visual mode starts and there's no existing selection, we launch CaretMode and try to establish a
// selection. As a heuristic, we pick the first non-whitespace character of the first visible text node
// which seems to be big enough to be interesting.
// TODO(smblott). It might be better to do something similar to Clearly or Readability; that is, try to find
// the start of the page's main textual content.
establishInitialSelectionAnchor() {
let node;
const nodes = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
while ((node = nodes.nextNode())) {
// Don't choose short text nodes; they're likely to be part of a banner.
if ((node.nodeType === 3) && (50 <= node.data.trim().length)) {
const element = node.parentElement;
if (DomUtils.getVisibleClientRect(element) && !DomUtils.isEditable(element)) {
// Start at the offset of the first non-whitespace character.
const offset = node.data.length - node.data.replace(/^\s+/, "").length;
const range = document.createRange();
range.setStart(node, offset);
range.setEnd(node, offset);
this.movement.setSelectionRange(range);
return true;
}
}
}
return false;
}
}
global.VisualMode = VisualMode;
global.VisualLineMode = VisualLineMode;

View file

@ -0,0 +1,420 @@
// activatedElement is different from document.activeElement -- the latter seems to be reserved mostly for
// input elements. This mechanism allows us to decide whether to scroll a div or to scroll the whole document.
let activatedElement = null;
// Previously, the main scrolling element was document.body. If the "experimental web platform features" flag
// is enabled, then we need to use document.scrollingElement instead. There's an explanation in #2168:
// https://github.com/philc/vimium/pull/2168#issuecomment-236488091
const getScrollingElement = () => getSpecialScrollingElement() || document.scrollingElement || document.body;
// Return 0, -1 or 1: the sign of the argument.
// NOTE(smblott; 2014/12/17) We would like to use Math.sign(). However, according to this site
// (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sign) Math.sign() was
// only introduced in Chrome 38. This caused problems in R1.48 for users with old Chrome installations. We
// can replace this with Math.sign() at some point.
// TODO(philc): 2020-04-28: now we can make this replacement.
const getSign = function(val) {
if (!val) {
return 0;
} else {
if (val < 0) { return -1; } else { return 1; }
}
};
const scrollProperties = {
x: {
axisName: 'scrollLeft',
max: 'scrollWidth',
viewSize: 'clientWidth'
},
y: {
axisName: 'scrollTop',
max: 'scrollHeight',
viewSize: 'clientHeight'
}
};
// Translate a scroll request into a number (which will be interpreted by `scrollBy` as a relative amount, or
// by `scrollTo` as an absolute amount). :direction must be "x" or "y". :amount may be either a number (in
// which case it is simply returned) or a string. If :amount is a string, then it is either "max" (meaning the
// height or width of element), or "viewSize". In both cases, we look up and return the requested amount,
// either in `element` or in `window`, as appropriate.
const getDimension = function(el, direction, amount) {
if (Utils.isString(amount)) {
const name = amount;
// the clientSizes of the body are the dimensions of the entire page, but the viewport should only be the
// part visible through the window
if ((name === 'viewSize') && (el === getScrollingElement())) {
// TODO(smblott) Should we not be returning the width/height of element, here?
return (direction === 'x') ? window.innerWidth : window.innerHeight;
} else {
return el[scrollProperties[direction][name]];
}
} else {
return amount;
}
};
// Perform a scroll. Return true if we successfully scrolled by any amount, and false otherwise.
const performScroll = function(element, direction, amount) {
const axisName = scrollProperties[direction].axisName;
const before = element[axisName];
if (element.scrollBy) {
const scrollArg = {behavior: "instant"};
scrollArg[direction === "x" ? "left" : "top"] = amount;
element.scrollBy(scrollArg);
} else {
element[axisName] += amount;
}
return element[axisName] !== before;
};
// Test whether `element` should be scrolled. E.g. hidden elements should not be scrolled.
const shouldScroll = function(element, direction) {
const computedStyle = window.getComputedStyle(element);
// Elements with `overflow: hidden` must not be scrolled.
if (computedStyle.getPropertyValue(`overflow-${direction}`) === "hidden")
return false;
// Elements which are not visible should not be scrolled.
if (["hidden", "collapse"].includes(computedStyle.getPropertyValue("visibility")))
return false;
if (computedStyle.getPropertyValue("display") === "none")
return false;
return true;
};
// Test whether element does actually scroll in the direction required when asked to do so. Due to chrome bug
// 110149, scrollHeight and clientHeight cannot be used to reliably determine whether an element will scroll.
// Instead, we scroll the element by 1 or -1 and see if it moved (then put it back). :factor is the factor by
// which :scrollBy and :scrollTo will later scale the scroll amount. :factor can be negative, so we need it
// here in order to decide whether we should test a forward scroll or a backward scroll.
// Bug last verified in Chrome 38.0.2125.104.
const doesScroll = function(element, direction, amount, factor) {
// amount is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls (only
// gg, G, and friends), amount can be either a string ("max" or "viewSize") or zero. In the former case,
// we're definitely scrolling forwards, so any positive value will do for delta. In the latter, we're
// definitely scrolling backwards, so a delta of -1 will do. For absolute scrolls, factor is always 1.
let delta = (factor * getDimension(element, direction, amount)) || -1;
delta = getSign(delta); // 1 or -1
return performScroll(element, direction, delta) && performScroll(element, direction, -delta);
};
const isScrollableElement = function(element, direction, amount, factor) {
if (direction == null) { direction = "y"; }
if (amount == null) { amount = 1; }
if (factor == null) { factor = 1; }
return doesScroll(element, direction, amount, factor) && shouldScroll(element, direction);
};
// From element and its parents, find the first which we should scroll and which does scroll.
const findScrollableElement = function(element, direction, amount, factor) {
while ((element !== getScrollingElement()) && !isScrollableElement(element, direction, amount, factor)) {
element = DomUtils.getContainingElement(element) || getScrollingElement();
}
return element;
};
// On some pages, the scrolling element is not actually scrollable. Here, we search the document for the
// largest visible element which does scroll vertically. This is used to initialize activatedElement. See
// #1358.
var firstScrollableElement = function(element = null) {
let child;
if (!element) {
const scrollingElement = getScrollingElement();
if (doesScroll(scrollingElement, "y", 1, 1) || doesScroll(scrollingElement, "y", -1, 1))
return scrollingElement;
else
element = document.body || getScrollingElement();
}
if (doesScroll(element, "y", 1, 1) || doesScroll(element, "y", -1, 1)) {
return element;
} else {
// children = children.filter (c) -> c.rect # Filter out non-visible elements.
let children = Array.from(element.children)
.map((c) => ({"element": c, "rect": DomUtils.getVisibleClientRect(c)})).
filter(child => child.rect); // Filter out non-visible elements.
children.map(child => child.area = child.rect.width * child.rect.height);
for (child of children.sort((a, b) => b.area - a.area)) { // Largest to smallest by visible area.
const el = firstScrollableElement(child.element);
if (el)
return el;
}
return null;
}
};
const checkVisibility = function(element) {
// If the activated element has been scrolled completely offscreen, then subsequent changes in its scroll
// position will not provide any more visual feedback to the user. Therefore, we deactivate it so that
// subsequent scrolls affect the parent element.
const rect = activatedElement.getBoundingClientRect();
if ((rect.bottom < 0) || (rect.top > window.innerHeight) || (rect.right < 0) || (rect.left > window.innerWidth)) {
return activatedElement = element;
}
};
// How scrolling is handled by CoreScroller.
// - For jump scrolling, the entire scroll happens immediately.
// - For smooth scrolling with distinct key presses, a separate animator is initiated for each key press.
// Therefore, several animators may be active at the same time. This ensures that two quick taps on `j`
// scroll to the same position as two slower taps.
// - For smooth scrolling with keyboard repeat (continuous scrolling), the most recently-activated animator
// continues scrolling at least until its keyup event is received. We never initiate a new animator on
// keyboard repeat.
// CoreScroller contains the core function (scroll) and logic for relative scrolls. All scrolls are ultimately
// translated to relative scrolls. CoreScroller is not exported.
const CoreScroller = {
init() {
this.time = 0;
this.lastEvent = (this.keyIsDown = null);
this.installCanceEventListener();
},
// This installs listeners for events which should cancel smooth scrolling.
installCanceEventListener() {
// NOTE(smblott) With extreme keyboard configurations, Chrome sometimes does not get a keyup event for
// every keydown, in which case tapping "j" scrolls indefinitely. This appears to be a Chrome/OS/XOrg bug
// of some kind. See #1549.
// TODO(philc): I believe some of these returns are unnecessary.
return handlerStack.push({
_name: 'scroller/track-key-status',
keydown: event => {
return handlerStack.alwaysContinueBubbling(() => {
this.keyIsDown = true;
if (!event.repeat) { this.time += 1; }
this.lastEvent = event;
});
},
keyup: event => {
return handlerStack.alwaysContinueBubbling(() => {
this.keyIsDown = false;
this.time += 1;
});
},
blur: event => {
return handlerStack.alwaysContinueBubbling(() => {
if (event.target === window) { this.time += 1; }
});
}
});
},
// Return true if CoreScroller would not initiate a new scroll right now.
wouldNotInitiateScroll() {
return this.lastEvent && this.lastEvent.repeat && Settings.get("smoothScroll");
},
// Calibration fudge factors for continuous scrolling. The calibration value starts at 1.0. We then
// increase it (until it exceeds @maxCalibration) if we guess that the scroll is too slow, or decrease it
// (until it is less than @minCalibration) if we guess that the scroll is too fast. The cutoff point for
// which guess we make is @calibrationBoundary. We require: 0 < @minCalibration <= 1 <= @maxCalibration.
minCalibration: 0.5, // Controls how much we're willing to slow scrolls down; smaller means more slow down.
maxCalibration: 1.6, // Controls how much we're willing to speed scrolls up; bigger means more speed up.
calibrationBoundary: 150, // Boundary between scrolls which are considered too slow, or too fast.
// Scroll element by a relative amount (a number) in some direction.
scroll(element, direction, amount, continuous) {
if (continuous == null) { continuous = true; }
if (!amount)
return;
if (!Settings.get("smoothScroll")) {
// Jump scrolling.
performScroll(element, direction, amount);
checkVisibility(element);
return;
}
// We don't activate new animators on keyboard repeats; rather, the most-recently activated animator
// continues scrolling.
if (this.lastEvent != null ? this.lastEvent.repeat : undefined)
return;
const activationTime = ++this.time;
const myKeyIsStillDown = () => (this.time === activationTime) && this.keyIsDown;
// Store amount's sign and make amount positive; the arithmetic is clearer when amount is positive.
const sign = getSign(amount);
amount = Math.abs(amount);
// Initial intended scroll duration (in ms). We allow a bit longer for longer scrolls.
const duration = Math.max(100, 20 * Math.log(amount));
let totalDelta = 0;
let totalElapsed = 0.0;
let calibration = 1.0;
let previousTimestamp = null;
const cancelEventListener = this.installCanceEventListener();
var animate = timestamp => {
if (previousTimestamp == null)
previousTimestamp = timestamp;
if (timestamp === previousTimestamp)
return requestAnimationFrame(animate);
// The elapsed time is typically about 16ms.
const elapsed = timestamp - previousTimestamp;
totalElapsed += elapsed;
previousTimestamp = timestamp;
// The constants in the duration calculation, above, are chosen to provide reasonable scroll speeds for
// distinct keypresses. For continuous scrolls, some scrolls are too slow, and others too fast. Here, we
// speed up the slower scrolls, and slow down the faster scrolls.
if (myKeyIsStillDown() && (75 <= totalElapsed) &&
(this.minCalibration <= calibration && calibration <= this.maxCalibration)) {
// Speed up slow scrolls.
if ((1.05 * calibration * amount) < this.calibrationBoundary)
calibration *= 1.05;
// Slow down fast scrolls.
if (this.calibrationBoundary < (0.95 * calibration * amount))
calibration *= 0.95;
}
// Calculate the initial delta, rounding up to ensure progress. Then, adjust delta to account for the
// current scroll state.
let delta = Math.ceil(amount * (elapsed / duration) * calibration);
delta = myKeyIsStillDown() ? delta : Math.max(0, Math.min(delta, amount - totalDelta));
if (delta && performScroll(element, direction, sign * delta)) {
totalDelta += delta;
return requestAnimationFrame(animate);
} else {
// We're done.
handlerStack.remove(cancelEventListener);
return checkVisibility(element);
}
};
// If we've been asked not to be continuous, then we advance time, so the myKeyIsStillDown test always
// fails.
if (!continuous)
++this.time;
// Start scrolling.
requestAnimationFrame(animate);
}
};
// Scroller contains the two main scroll functions which are used by clients.
const Scroller = {
init() {
const handler = {_name: 'scroller/active-element'};
// Only Chrome has a DOMActivate event. On Firefox, we must listen for click. See #3287.
const eventName = Utils.isFirefox() ? "click" : "DOMActivate";
handler[eventName] = event => handlerStack.alwaysContinueBubbling(function() {
// If event.path is present, the true event taget (potentially inside a Shadow DOM inside
// event.target) can be found as its first element.
// NOTE(mrmr1993): event.path has been renamed to event.deepPath in the spec, but this change is not
// yet implemented by Chrome.
const path = event.deepPath || event.path;
return activatedElement = path ? path[0] : event.target;
});
handlerStack.push(handler);
CoreScroller.init();
this.reset();
},
reset() {
activatedElement = null;
},
// scroll the active element in :direction by :amount * :factor.
// :factor is needed because :amount can take on string values, which scrollBy converts to element dimensions.
scrollBy(direction, amount, factor, continuous) {
// if this is called before domReady, just use the window scroll function
if (factor == null)
factor = 1;
if (continuous == null)
continuous = true;
if (!getScrollingElement() && amount instanceof Number) {
if (direction === "x")
window.scrollBy(amount, 0);
else
window.scrollBy(0, amount);
return;
}
if (!activatedElement)
activatedElement = (getScrollingElement() && firstScrollableElement()) || getScrollingElement();
if (!activatedElement)
return;
// Avoid the expensive scroll calculation if it will not be used. This reduces costs during smooth,
// continuous scrolls, and is just an optimization.
if (!CoreScroller.wouldNotInitiateScroll()) {
const element = findScrollableElement(activatedElement, direction, amount, factor);
const elementAmount = factor * getDimension(element, direction, amount);
return CoreScroller.scroll(element, direction, elementAmount, continuous);
}
},
scrollTo(direction, pos) {
if (!activatedElement)
activatedElement = (getScrollingElement() && firstScrollableElement()) || getScrollingElement();
if (!activatedElement)
return
const element = findScrollableElement(activatedElement, direction, pos, 1);
const amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName];
return CoreScroller.scroll(element, direction, amount);
},
// Is element scrollable and not the activated element?
isScrollableElement(element) {
if (!activatedElement)
activatedElement = (getScrollingElement() && firstScrollableElement()) || getScrollingElement();
return (element !== activatedElement) && isScrollableElement(element);
},
// Scroll the top, bottom, left and right of element into view. The is used by visual mode to ensure the
// focus remains visible.
scrollIntoView(element) {
if (!activatedElement)
activatedElement = getScrollingElement() && firstScrollableElement();
const rects = element.getClientRects();
const rect = rects ? rects[0] : undefined;
if (rect) {
// Scroll y axis.
let amount;
if (rect.bottom < 0) {
amount = rect.bottom - Math.min(rect.height, window.innerHeight);
element = findScrollableElement(element, "y", amount, 1);
CoreScroller.scroll(element, "y", amount, false);
} else if (window.innerHeight < rect.top) {
amount = rect.top + Math.min(rect.height - window.innerHeight, 0);
element = findScrollableElement(element, "y", amount, 1);
CoreScroller.scroll(element, "y", amount, false);
}
// Scroll x axis.
if (rect.right < 0) {
amount = rect.right - Math.min(rect.width, window.innerWidth);
element = findScrollableElement(element, "x", amount, 1);
CoreScroller.scroll(element, "x", amount, false);
} else if (window.innerWidth < rect.left) {
amount = rect.left + Math.min(rect.width - window.innerWidth, 0);
element = findScrollableElement(element, "x", amount, 1);
CoreScroller.scroll(element, "x", amount, false);
}
}
}
};
var getSpecialScrollingElement = function() {
const selector = specialScrollingElementMap[window.location.host];
if (selector)
return document.querySelector(selector);
};
var specialScrollingElementMap = {
'twitter.com': 'div.permalink-container div.permalink[role=main]',
'reddit.com': '#overlayScrollContainer',
'new.reddit.com': '#overlayScrollContainer',
'www.reddit.com': '#overlayScrollContainer',
'web.telegram.org': '.MessageList',
};
global.Scroller = Scroller;

View file

@ -0,0 +1,178 @@
class UIComponent {
constructor(iframeUrl, className, handleMessage) {
this.handleMessage = handleMessage;
this.iframeElement = null;
this.iframePort = null;
this.showing = false;
this.iframeFrameId = null;
// TODO(philc): Make the @options object default to {} and remove the null checks.
this.options = null;
this.shadowDOM = null;
DomUtils.documentReady(() => {
const styleSheet = DomUtils.createElement("style");
styleSheet.type = "text/css";
// Default to everything hidden while the stylesheet loads.
styleSheet.innerHTML = "iframe {display: none;}";
// Fetch "content_scripts/vimium.css" from chrome.storage.local; the background page caches it there.
chrome.storage.local.get("vimiumCSSInChromeStorage",
items => styleSheet.innerHTML = items.vimiumCSSInChromeStorage);
this.iframeElement = DomUtils.createElement("iframe");
Object.assign(this.iframeElement, {
className,
seamless: "seamless"
});
const shadowWrapper = DomUtils.createElement("div");
// Firefox doesn't support createShadowRoot, so guard against its non-existance.
// https://hacks.mozilla.org/2018/10/firefox-63-tricks-and-treats/ says
// Firefox 63 has enabled Shadow DOM v1 by default
if (shadowWrapper.attachShadow)
this.shadowDOM = shadowWrapper.attachShadow({mode: "open"});
else
this.shadowDOM = shadowWrapper;
this.shadowDOM.appendChild(styleSheet);
this.shadowDOM.appendChild(this.iframeElement);
this.handleDarkReaderFilter();
this.toggleIframeElementClasses("vimiumUIComponentVisible", "vimiumUIComponentHidden");
// Open a port and pass it to the iframe via window.postMessage. We use an AsyncDataFetcher to handle
// requests which arrive before the iframe (and its message handlers) have completed initialization. See
// #1679.
this.iframePort = new AsyncDataFetcher(setIframePort => {
// We set the iframe source and append the new element here (as opposed to above) to avoid a potential
// race condition vis-a-vis the "load" event (because this callback runs on "nextTick").
this.iframeElement.src = chrome.runtime.getURL(iframeUrl);
document.documentElement.appendChild(shadowWrapper);
this.iframeElement.addEventListener("load", () => {
// Get vimiumSecret so the iframe can determine that our message isn't the page impersonating us.
chrome.storage.local.get("vimiumSecret", ({ vimiumSecret }) => {
const { port1, port2 } = new MessageChannel;
this.iframeElement.contentWindow.postMessage(vimiumSecret, chrome.runtime.getURL(""), [ port2 ]);
port1.onmessage = event => {
let eventName = null;
if (event)
eventName = (event.data ? event.data.name : undefined) || event.data;
switch (eventName) {
case "uiComponentIsReady":
// If any other frame receives the focus, then hide the UI component.
chrome.runtime.onMessage.addListener(({name, focusFrameId}) => {
if ((name === "frameFocused") && this.options && this.options.focus &&
![frameId, this.iframeFrameId].includes(focusFrameId)) {
this.hide(false);
}
// We will not be calling sendResponse.
return false;
});
// If this frame receives the focus, then hide the UI component.
window.addEventListener("focus", (forTrusted(event => {
if ((event.target === window) && this.options && this.options.focus)
this.hide(false);
// Continue propagating the event.
return true;
})), true);
// Set the iframe's port, thereby rendering the UI component ready.
setIframePort(port1);
break;
case "setIframeFrameId":
this.iframeFrameId = event.data.iframeFrameId;
break;
case "hide":
return this.hide();
break;
default:
this.handleMessage(event);
}
};
});
});
});
if (Utils.isFirefox()) {
this.postMessage({name: "settings", isFirefox: true});
}
});
}
// This ensures that Vimium's UI elements (HUD, Vomnibar) honor the browser's light/dark theme preference,
// even when the user is also using the DarkReader extension. DarkReader is the most popular dark mode
// Chrome extension in use as of 2020.
handleDarkReaderFilter() {
const reverseFilterClass = "reverseDarkReaderFilter";
const reverseFilterIfExists = () => {
// The DarkReader extension creates this element if it's actively modifying the current page.
const darkReaderElement = document.getElementById("dark-reader-style");
if (darkReaderElement && darkReaderElement.innerHTML.includes("filter"))
this.iframeElement.classList.add(reverseFilterClass);
else
this.iframeElement.classList.remove(reverseFilterClass);
};
reverseFilterIfExists();
const observer = new MutationObserver(reverseFilterIfExists);
observer.observe(document.head, { characterData: true, subtree: true, childList: true });
};
toggleIframeElementClasses(removeClass, addClass) {
this.iframeElement.classList.remove(removeClass);
this.iframeElement.classList.add(addClass);
}
// Post a message (if provided), then call continuation (if provided). We wait for documentReady() to ensure
// that the @iframePort set (so that we can use @iframePort.use()).
postMessage(message = null, continuation = null) {
if (!this.iframePort)
return
this.iframePort.use(function(port) {
if (message != null)
port.postMessage(message);
if (continuation)
continuation();
});
}
activate(options = null) {
this.options = options;
this.postMessage(this.options, () => {
this.toggleIframeElementClasses("vimiumUIComponentHidden", "vimiumUIComponentVisible");
if (this.options && this.options.focus)
this.iframeElement.focus();
this.showing = true;
});
}
hide(shouldRefocusOriginalFrame) {
// We post a non-message (null) to ensure that hide() requests cannot overtake activate() requests.
if (shouldRefocusOriginalFrame == null) { shouldRefocusOriginalFrame = true; }
this.postMessage(null, () => {
if (!this.showing) { return; }
this.showing = false;
this.toggleIframeElementClasses("vimiumUIComponentVisible", "vimiumUIComponentHidden");
if (this.options && this.options.focus) {
this.iframeElement.blur();
if (shouldRefocusOriginalFrame) {
if (this.options && (this.options.sourceFrameId != null)) {
chrome.runtime.sendMessage({
handler: "sendMessageToFrames",
message: { name: "focusFrame", frameId: this.options.sourceFrameId, forceFocusThisFrame: true }
});
} else {
Utils.nextTick(() => window.focus());
}
}
}
this.options = null;
this.postMessage("hidden"); // Inform the UI component that it is hidden.
});
}
}
global.UIComponent = UIComponent;

View file

@ -0,0 +1,519 @@
/*
* Many CSS class names in this file use the verbose "vimiumXYZ" as the class name. This is so we don't
* use the same CSS class names that the page is using, so the page's CSS doesn't mess with the style of our
* Vimium dialogs.
*
* The z-indexes of Vimium elements are very large, because we always want them to show on top. We know
* that Chrome supports z-index values up to about 2^31. The values we use are large numbers approaching
* that bound. However, we must leave headroom for link hints. Hint marker z-indexes start at 2140000001.
*/
/*
* This vimiumReset class can be added to any of our UI elements to give it a clean slate. This is useful in
* case the page has declared a broad rule like "a { padding: 50px; }" which will mess up our UI. These
* declarations contain more specifiers than necessary to increase their specificity (precedence).
*/
.vimiumReset,
div.vimiumReset,
span.vimiumReset,
table.vimiumReset,
a.vimiumReset,
a:visited.vimiumReset,
a:link.vimiumReset,
a:hover.vimiumReset,
td.vimiumReset,
tr.vimiumReset {
background: none;
border: none;
bottom: auto;
box-shadow: none;
color: black;
cursor: auto;
display: inline;
float: none;
font-family : "Helvetica Neue", "Helvetica", "Arial", sans-serif;
font-size: inherit;
font-style: normal;
font-variant: normal;
font-weight: normal;
height: auto;
left: auto;
letter-spacing: 0;
line-height: 100%;
margin: 0;
max-height: none;
max-width: none;
min-height: 0;
min-width: 0;
opacity: 1;
padding: 0;
position: static;
right: auto;
text-align: left;
text-decoration: none;
text-indent: 0;
text-shadow: none;
text-transform: none;
top: auto;
vertical-align: baseline;
white-space: normal;
width: auto;
z-index: 2140000000; /* Vimium's reference z-index value. */
}
thead.vimiumReset, tbody.vimiumReset {
display: table-header-group;
}
tbody.vimiumReset {
display: table-row-group;
}
/* Linkhints CSS */
div.internalVimiumHintMarker {
position: absolute;
display: block;
top: -1px;
left: -1px;
white-space: nowrap;
overflow: hidden;
font-size: 11px;
padding: 1px 3px 0px 3px;
background: linear-gradient(to bottom, #FFF785 0%,#FFC542 100%);
border: solid 1px #C38A22;
border-radius: 3px;
box-shadow: 0px 3px 7px 0px rgba(0, 0, 0, 0.3);
}
div.internalVimiumHintMarker span {
color: #302505;
font-family: Helvetica, Arial, sans-serif;
font-weight: bold;
font-size: 11px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);
}
div.internalVimiumHintMarker > .matchingCharacter {
color: #D4AC3A;
}
div > .vimiumActiveHintMarker span {
color: #A07555 !important;
}
/* Input hints CSS */
div.internalVimiumInputHint {
position: absolute;
display: block;
background-color: rgba(255, 247, 133, 0.3);
border: solid 1px #C38A22;
pointer-events: none;
}
div.internalVimiumSelectedInputHint {
background-color: rgba(255, 102, 102, 0.3);
border: solid 1px #993333 !important;
}
div.internalVimiumSelectedInputHint span {
color: white !important;
}
/* Frame Highlight Marker CSS*/
div.vimiumHighlightedFrame {
position: fixed;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
padding: 0px;
margin: 0px;
border: 5px solid yellow;
box-sizing: border-box;
pointer-events: none;
}
/* Help Dialog CSS */
iframe.vimiumHelpDialogFrame {
background-color: rgba(10,10,10,0.6);
padding: 0px;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
display: block;
position: fixed;
border: none;
z-index: 2139999997; /* Three less than the reference value. */
}
div#vimiumHelpDialogContainer {
opacity:1.0;
background-color:white;
border:2px solid #b3b3b3;
border-radius:6px;
width: 840px;
max-width: calc(100% - 100px);
max-height: calc(100% - 100px);
margin: 50px auto;
overflow-y: auto;
overflow-x: auto;
}
div#vimiumHelpDialog {
min-width: 600px;
padding:8px 12px;
}
span#vimiumTitle, span#vimiumTitle span, span#vimiumTitle * { font-size:20px; }
#vimiumTitle {
display: block;
line-height: 130%;
white-space: nowrap;
}
td.vimiumHelpDialogTopButtons {
width: 100%;
text-align: right;
}
#helpDialogOptionsPage, #helpDialogWikiPage {
font-size: 14px;
padding-left: 5px;
padding-right: 5px;
}
div.vimiumColumn {
width:50%;
float:left;
font-size: 11px;
line-height: 130%;
}
div.vimiumColumn tr {
display: table-row;
}
div.vimiumColumn td {
display: table-cell;
font-size: 11px;
line-height: 130%;
}
div.vimiumColumn table, div.vimiumColumn td, div.vimiumColumn tr { padding:0; margin:0; }
div.vimiumColumn table { width:100%; table-layout:auto; }
div.vimiumColumn td { vertical-align:top; padding:1px; }
div#vimiumHelpDialog div.vimiumColumn tr > td:first-of-type {
/* This is the "key" column, e.g. "j", "gg". */
font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;
font-size:14px;
text-align:right;
white-space:nowrap;
}
span.vimiumHelpDialogKey {
background-color: rgb(243,243,243);
color: rgb(33,33,33);
margin-left: 2px;
padding-top: 1px;
padding-bottom: 1px;
padding-left: 4px;
padding-right: 4px;
border-radius: 3px;
border: solid 1px #ccc;
border-bottom-color: #bbb;
box-shadow: inset 0 -1px 0 #bbb;
font-family: monospace;
font-size: 11px;
}
/* Make the description column as wide as it can be. */
div#vimiumHelpDialog div.vimiumColumn tr > td:nth-of-type(3) { width:100%; }
div#vimiumHelpDialog div.vimiumDivider {
display: block;
height:1px;
width:100%;
margin:10px auto;
background-color:#9a9a9a;
}
div#vimiumHelpDialog td.vimiumHelpSectionTitle {
padding-top:3px;
font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;
font-size:16px;
font-weight:bold;
}
div#vimiumHelpDialog td.vimiumHelpDescription {
font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;
font-size:14px;
}
div#vimiumHelpDialog span.vimiumCopyCommandNameName {
font-style: italic;
cursor: pointer;
font-size: 12px;
}
/* Advanced commands are hidden by default until you show them. */
div#vimiumHelpDialog tr.advanced { display: none; }
div#vimiumHelpDialog.showAdvanced tr.advanced { display: table-row; }
div#vimiumHelpDialog div.advanced td:nth-of-type(3) { color: #555; }
div#vimiumHelpDialog a.closeButton {
font-family:"courier new";
font-weight:bold;
color:#555;
text-decoration:none;
font-size:24px;
position: relative;
top: 3px;
padding-left: 5px;
cursor: pointer;
}
div#vimiumHelpDialog a {
text-decoration: underline;
}
div#vimiumHelpDialog a.closeButton:hover {
color:black;
-webkit-user-select:none;
}
div#vimiumHelpDialogFooter {
display: block;
position: relative;
margin-bottom: 37px;
}
table.helpDialogBottom {
width:100%;
}
td.helpDialogBottomRight {
width:100%;
float:right;
text-align: right;
}
td.helpDialogBottomRight, td.helpDialogBottomLeft {
padding: 0px;
}
div#vimiumHelpDialogFooter * { font-size:10px; }
a#toggleAdvancedCommands, span#help-dialog-tip {
position: relative;
top: 19px;
white-space: nowrap;
font-size: 10px;
}
a:link.vimiumHelDialogLink, a:visited.vimiumHelDialogLink,
a:hover.vimiumHelDialogLink, a:active.vimiumHelDialogLink, a#toggleAdvancedCommands {
color:#2f508e;
text-decoration: underline;
cursor: pointer;
}
/* Vimium HUD CSS */
div.vimiumHUD {
display: block;
position: fixed;
width: calc(100% - 20px);
bottom: 8px;
left: 8px;
background: #F1F1F1;
text-align: left;
border-radius: 4px;
box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8);
border: 1px solid #aaa;
z-index: 2139999999;
}
iframe.vimiumHUDFrame {
background-color: transparent;
padding: 0px;
overflow: hidden;
display: block;
position: fixed;
width: 20%;
min-width: 300px;
height: 58px;
bottom: -14px;
right: 20px;
margin: 0 0 0 -40%;
border: none;
z-index: 2139999998; /* Two less than the reference value. */
opacity: 0;
}
div.vimiumHUD .vimiumHUDSearchArea {
display: block;
padding: 3px;
background-color: #F1F1F1;
border-radius: 4px 4px 0 0;
}
div.vimiumHUD .vimiumHUDSearchAreaInner {
color: #777;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
height: 30px;
margin-bottom: 0;
padding: 2px 4px;
border-radius: 3px;
width: 100%;
outline: none;
box-sizing: border-box;
line-height: 20px;
}
div.vimiumHUD .hud-find {
background: #fff;
border: 1px solid #ccc;
}
div.vimiumHUD span#hud-find-input, div.vimiumHUD span#hud-match-count {
color: #000;
display: inline;
outline: none;
white-space: nowrap;
overflow-y: hidden;
}
div.vimiumHUD span#hud-find-input:before {
content: "/";
}
div.vimiumHUD span#hud-match-count {
color: #aaa;
font-size: 12px;
}
div.vimiumHUD span#hud-find-input br {
display: none;
}
div.vimiumHUD span#hud-find-input * {
display: inline;
white-space: nowrap;
}
body.vimiumFindMode ::selection {
background: #ff9632;
}
/* Vomnibar Frame CSS */
iframe.vomnibarFrame {
background-color: transparent;
padding: 0px;
overflow: hidden;
display: block;
position: fixed;
width: calc(80% + 20px); /* same adjustment as in pages/vomnibar.js */
min-width: 400px;
height: calc(100% - 70px);
top: 70px;
left: 50%;
margin: 0 0 0 -40%;
border: none;
font-family: sans-serif;
z-index: 2139999998; /* Two less than the reference value. */
}
div.vimiumFlash {
box-shadow: 0px 0px 4px 2px #4183C4;
padding: 1px;
background-color: transparent;
position: absolute;
z-index: 2140000000;
}
/* UIComponent CSS */
iframe.vimiumUIComponentHidden {
display: none;
}
iframe.vimiumUIComponentVisible {
display: block;
}
iframe.vimiumUIComponentReactivated {
border: 5px solid yellow;
}
iframe.vimiumNonClickable {
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
/* DarkReader is a popular dark mode browser extension. It can apply an invert filter to the whole page to
* make the page dark, when used in Filter and Filter+ modes. We want to reverse/invert that filter again
* for Vimium's UI elements, because Vimium is already dark mode aware. */
iframe.reverseDarkReaderFilter {
-webkit-filter: invert(100%) hue-rotate(180deg) !important;
filter: invert(100%) hue-rotate(180deg) !important;
}
/* Dark mode CSS for options page and exclusions */
body.vimiumBody {
background-color: #292a2d;
color: white;
}
body.vimiumBody a,
body.vimiumBody a:visited {
color: #8ab4f8;
}
body.vimiumBody textarea,
body.vimiumBody input {
background-color: #1d1d1f;
border-color: #1d1d1f;
color: #e8eaed;
}
body.vimiumBody div.example {
color: #9aa0a6;
}
body.vimiumBody div#state,
body.vimiumBody div#footer {
background-color: #202124;
border-color: rgba(255, 255, 255, 0.1);
}
/* Dark Mode CSS for Help Dialog */
div#vimiumHelpDialogContainer {
border-color: rgba(255, 255, 255, 0.1);
background-color: #202124;
}
div#vimiumHelpDialog {
background-color: #292a2d;
color: white;
}
div#vimiumHelpDialog td.vimiumHelpDescription {
color: #c9cccf;
}
span#vimiumTitle,
div#vimiumHelpDialog td.vimiumHelpSectionTitle {
color: white;
}
#vimiumTitle > span:first-child {
color: #8ab4f8 !important;
}
div#vimiumHelpDialog a {
color: #8ab4f8;
}
div#vimiumHelpDialog div.vimiumDivider {
background-color: rgba(255, 255, 255, 0.1);
}
span.vimiumHelpDialogKey {
background-color: #1d1d1f;
border: solid 1px black;
box-shadow: none;
color: white;
}
}
.vimiumUIComponentVisible {
color-scheme: light;
}

View file

@ -0,0 +1,491 @@
//
// This content script must be run prior to domReady so that we perform some operations very early.
//
root = typeof exports !== 'undefined' && exports !== null ? exports : (window.root != null ? window.root : (window.root = {}));
// On Firefox, sometimes the variables assigned to window are lost (bug 1408996), so we reinstall them.
// NOTE(mrmr1993): This bug leads to catastrophic failure (ie. nothing works and errors abound).
DomUtils.documentReady(function() {
if (typeof global === 'undefined' || global === null) { return Object.assign(window, root); }
});
let isEnabledForUrl = true;
const isIncognitoMode = chrome.extension.inIncognitoContext;
let normalMode = null;
// We track whther the current window has the focus or not.
const windowIsFocused = (function() {
let windowHasFocus = null;
DomUtils.documentReady(() => windowHasFocus = document.hasFocus());
window.addEventListener("focus", (forTrusted(function(event) {
if (event.target === window)
windowHasFocus = true;
return true;
})), true);
window.addEventListener("blur", (forTrusted(function(event) {
if (event.target === window)
windowHasFocus = false;
return true;
})), true);
return () => windowHasFocus;
})();
// This is set by Frame.registerFrameId(). A frameId of 0 indicates that this is the top frame in the tab.
let frameId = null;
// For debugging only. This writes to the Vimium log page, the URL of whichis shown on the console on the
// background page.
const bgLog = function(...args) {
args = args.map(a => a.toString());
Frame.postMessage("log", {message: args.join(" ")});
};
// If an input grabs the focus before the user has interacted with the page, then grab it back (if the
// grabBackFocus option is set).
class GrabBackFocus extends Mode {
constructor() {
super();
let listener;
const exitEventHandler = () => {
return this.alwaysContinueBubbling(() => {
this.exit();
chrome.runtime.sendMessage({handler: "sendMessageToFrames",
message: {name: "userIsInteractingWithThePage"}});
});
};
super.init({
name: "grab-back-focus",
keydown: exitEventHandler
});
// True after we've grabbed back focus to the page and logged it via console.log , so web devs using Vimium
// don't get confused.
this.logged = false;
this.push({
_name: "grab-back-focus-mousedown",
mousedown: exitEventHandler
});
Settings.use("grabBackFocus", grabBackFocus => {
// It is possible that this mode exits (e.g. due to a key event) before the settings are ready -- in
// which case we should not install this grab-back-focus watcher.
if (this.modeIsActive) {
if (grabBackFocus) {
this.push({
_name: "grab-back-focus-focus",
focus: event => this.grabBackFocus(event.target)
});
// An input may already be focused. If so, grab back the focus.
if (document.activeElement)
this.grabBackFocus(document.activeElement);
} else {
this.exit();
}
}
});
// This mode is active in all frames. A user might have begun interacting with one frame without other
// frames detecting this. When one GrabBackFocus mode exits, we broadcast a message to inform all
// GrabBackFocus modes that they should exit; see #2296.
chrome.runtime.onMessage.addListener(listener = ({name}) => {
if (name === "userIsInteractingWithThePage") {
chrome.runtime.onMessage.removeListener(listener);
if (this.modeIsActive)
this.exit();
}
// We will not be calling sendResponse.
return false;
});
}
grabBackFocus(element) {
if (!DomUtils.isFocusable(element))
return this.continueBubbling;
if (!this.logged && (element !== document.body)) {
this.logged = true;
if (!window.vimiumDomTestsAreRunning)
console.log("An auto-focusing action on this page was blocked by Vimium.");
}
element.blur();
return this.suppressEvent;
}
}
// Pages can load new content dynamically and change the displayed URL using history.pushState. Since this can
// often be indistinguishable from an actual new page load for the user, we should also re-start GrabBackFocus
// for these as well. This fixes issue #1622.
handlerStack.push({
_name: "GrabBackFocus-pushState-monitor",
click(event) {
// If a focusable element is focused, the user must have clicked on it. Retain focus and bail.
if (DomUtils.isFocusable(document.activeElement))
return true;
let target = event.target;
while (target) {
// Often, a link which triggers a content load and url change with javascript will also have the new
// url as it's href attribute.
if ((target.tagName === "A") &&
(target.origin === document.location.origin) &&
// Clicking the link will change the url of this frame.
((target.pathName !== document.location.pathName) ||
(target.search !== document.location.search)) &&
(["", "_self"].includes(target.target) ||
((target.target === "_parent") && (window.parent === window)) ||
((target.target === "_top") && (window.top === window)))) {
return new GrabBackFocus();
} else {
target = target.parentElement;
}
}
return true;
}
});
const installModes = function() {
// Install the permanent modes. The permanently-installed insert mode tracks focus/blur events, and
// activates/deactivates itself accordingly.
normalMode = new NormalMode();
normalMode.init();
// Initialize components upon which normal mode depends.
Scroller.init();
FindModeHistory.init();
new InsertMode({permanent: true});
if (isEnabledForUrl) {
new GrabBackFocus;
}
// Return the normalMode object (for the tests).
return normalMode;
};
//
// Complete initialization work that should be done prior to DOMReady.
//
const initializePreDomReady = function() {
installListeners();
Frame.init();
checkIfEnabledForUrl(document.hasFocus());
const requestHandlers = {
focusFrame(request) {
if (frameId === request.frameId)
return focusThisFrame(request);
},
getScrollPosition(ignoredA, ignoredB, sendResponse) {
if (frameId === 0)
return sendResponse({scrollX: window.scrollX, scrollY: window.scrollY});
},
setScrollPosition,
frameFocused() {}, // A frame has received the focus; we don't care here (UI components handle this).
checkEnabledAfterURLChange,
runInTopFrame({sourceFrameId, registryEntry}) {
if (DomUtils.isTopFrame())
return NormalModeCommands[registryEntry.command](sourceFrameId, registryEntry);
},
linkHintsMessage(request) { return HintCoordinator[request.messageType](request); },
showMessage(request) { return HUD.showForDuration(request.message, 2000); },
executeScript(request) { return DomUtils.injectUserScript(request.script); }
};
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
request.isTrusted = true;
// Some requests intended for the background page are delivered to the options page too; ignore them.
if (!request.handler || !!request.name) {
// Some request are handled elsewhere; ignore them too.
if (request.name !== "userIsInteractingWithThePage")
if (isEnabledForUrl || ["checkEnabledAfterURLChange", "runInTopFrame"].includes(request.name))
requestHandlers[request.name](request, sender, sendResponse);
}
// Ensure that the sendResponse callback is freed.
return false;
});
};
// Wrapper to install event listeners. Syntactic sugar.
const installListener = (element, event, callback) => element.addEventListener(event, forTrusted(function() {
if (typeof global === 'undefined' || global === null) { // See #2800.
Object.assign(window, root);
}
if (isEnabledForUrl)
return callback.apply(this, arguments);
else
return true;
}), true);
//
// Installing or uninstalling listeners is error prone. Instead we elect to check isEnabledForUrl each time so
// we know whether the listener should run or not.
// Run this as early as possible, so the page can't register any event handlers before us.
// Note: We install the listeners even if Vimium is disabled. See comment in commit
// 6446cf04c7b44c3d419dc450a73b60bcaf5cdf02.
//
var installListeners = Utils.makeIdempotent(function() {
// Key event handlers fire on window before they do on document. Prefer window for key events so the page
// can't set handlers to grab the keys before us.
for (let type of ["keydown", "keypress", "keyup", "click", "focus", "blur", "mousedown", "scroll"]) {
// TODO(philc): Can we remove this extra closure?
((type => installListener(window, type, event => handlerStack.bubbleEvent(type, event))))(type);
}
return installListener(document, "DOMActivate", event => handlerStack.bubbleEvent('DOMActivate', event));
});
// Whenever we get the focus:
// - Tell the background page this frame's URL.
// - Check if we should be enabled.
const onFocus = forTrusted(function(event) {
if (event.target === window) {
chrome.runtime.sendMessage({handler: "frameFocused"});
checkIfEnabledForUrl(true);
}
});
// We install these listeners directly (that is, we don't use installListener) because we still need to receive
// events when Vimium is not enabled.
window.addEventListener("focus", onFocus, true);
window.addEventListener("hashchange", checkEnabledAfterURLChange, true);
const initializeOnDomReady = () => // Tell the background page we're in the domReady state.
Frame.postMessage("domReady");
var Frame = {
port: null,
listeners: {},
addEventListener(handler, callback) {
this.listeners[handler] = callback;
},
postMessage(handler, request) {
if (request == null)
request = {};
this.port.postMessage(Object.assign(request, {handler}));
},
linkHintsMessage(request) {
return HintCoordinator[request.messageType](request);
},
registerFrameId({chromeFrameId}) {
frameId = (root.frameId = (window.frameId = chromeFrameId));
// We register a frame immediately only if it is focused or its window isn't tiny. We register tiny
// frames later, when necessary. This affects focusFrame() and link hints.
if (windowIsFocused() || !DomUtils.windowIsTooSmall()) {
return Frame.postMessage("registerFrame");
} else {
let focusHandler, resizeHandler;
const postRegisterFrame = function() {
window.removeEventListener("focus", focusHandler, true);
window.removeEventListener("resize", resizeHandler, true);
return Frame.postMessage("registerFrame");
};
window.addEventListener("focus", (focusHandler = forTrusted(function(event) {
if (event.target === window)
postRegisterFrame();
})), true);
return window.addEventListener("resize", (resizeHandler = forTrusted(function(event) {
if (!DomUtils.windowIsTooSmall())
postRegisterFrame();
})), true);
}
},
init() {
let disconnect;
this.port = chrome.runtime.connect({name: "frames"});
this.port.onMessage.addListener(request => {
if (typeof global === 'undefined' || global === null) { // See #2800 and #2831.
Object.assign(window, root);
}
const handler = this.listeners[request.handler] || this[request.handler];
handler(request);
// return (this.listeners[request.handler] != null ? this.listeners[request.handler] : this[request.handler])(request);
});
// We disable the content scripts when we lose contact with the background page, or on unload.
this.port.onDisconnect.addListener(disconnect = Utils.makeIdempotent(() => this.disconnect()));
return window.addEventListener("unload", (forTrusted(function(event) {
if (event.target === window)
return disconnect();
})), true);
},
disconnect() {
try { this.postMessage("unregisterFrame"); } catch (error) {}
try { this.port.disconnect(); } catch (error1) {}
this.postMessage = this.disconnect = function() {};
this.port = null;
this.listeners = {};
HintCoordinator.exit({isSuccess: false});
handlerStack.reset();
isEnabledForUrl = false;
window.removeEventListener("focus", onFocus, true);
window.removeEventListener("hashchange", checkEnabledAfterURLChange, true);
}
};
var setScrollPosition = ({ scrollX, scrollY }) => DomUtils.documentReady(function() {
if (DomUtils.isTopFrame()) {
Utils.nextTick(function() {
window.focus();
document.body.focus();
if ((scrollX > 0) || (scrollY > 0)) {
Marks.setPreviousPosition();
window.scrollTo(scrollX, scrollY);
}
});
}
});
const flashFrame = (function() {
let highlightedFrameElement = null;
return function() {
if (highlightedFrameElement == null) {
// TODO(philc): Make this a regular if body, not a closure.
highlightedFrameElement = (function() {
// Create a shadow DOM wrapping the frame so the page's styles don't interfere with ours.
highlightedFrameElement = DomUtils.createElement("div");
// Firefox doesn't support createShadowRoot, so guard against its non-existance.
// https://hacks.mozilla.org/2018/10/firefox-63-tricks-and-treats/ says
// Firefox 63 has enabled Shadow DOM v1 by default
const _shadowDOM =
highlightedFrameElement.attachShadow ?
highlightedFrameElement.attachShadow({mode: "open"}) :
highlightedFrameElement;
// Inject stylesheet.
const _styleSheet = DomUtils.createElement("style");
_styleSheet.innerHTML = `@import url(\"${chrome.runtime.getURL("content_scripts/vimium.css")}\");`;
_shadowDOM.appendChild(_styleSheet);
const _frameEl = DomUtils.createElement("div");
_frameEl.className = "vimiumReset vimiumHighlightedFrame";
_shadowDOM.appendChild(_frameEl);
return highlightedFrameElement;
})();
}
document.documentElement.appendChild(highlightedFrameElement);
Utils.setTimeout(200, () => highlightedFrameElement.remove());
};
})();
//
// Called from the backend in order to change frame focus.
//
var focusThisFrame = function(request) {
if (!request.forceFocusThisFrame) {
if (DomUtils.windowIsTooSmall() || (document.body && document.body.tagName.toLowerCase() == "frameset")) {
// This frame is too small to focus or it's a frameset. Cancel and tell the background page to focus the
// next frame instead. This affects sites like Google Inbox, which have many tiny iframes. See #1317.
chrome.runtime.sendMessage({handler: "nextFrame"});
return;
}
}
Utils.nextTick(function() {
window.focus();
// On Firefox, window.focus doesn't always draw focus back from a child frame (bug 554039).
// We blur the active element if it is an iframe, which gives the window back focus as intended.
if (document.activeElement.tagName.toLowerCase() === "iframe")
document.activeElement.blur();
if (request.highlight)
flashFrame();
});
};
// Used by focusInput command.
root.lastFocusedInput = (function() {
// Track the most recently focused input element.
let recentlyFocusedElement = null;
window.addEventListener("focus",
forTrusted(function(event) {
const DomUtils = window.DomUtils || root.DomUtils; // Workaround FF bug 1408996.
if (DomUtils.isEditable(event.target))
recentlyFocusedElement = event.target;
})
, true);
return () => recentlyFocusedElement;
})();
// Checks if Vimium should be enabled or not in this frame. As a side effect, it also informs the background
// page whether this frame has the focus, allowing the background page to track the active frame's URL and set
// the page icon.
var checkIfEnabledForUrl = (function() {
Frame.addEventListener("isEnabledForUrl", function(response) {
let frameIsFocused, isFirefox, passKeys;
({isEnabledForUrl, passKeys, frameIsFocused, isFirefox} = response);
Utils.isFirefox = () => isFirefox;
if (!normalMode)
installModes();
normalMode.setPassKeys(passKeys);
// Hide the HUD if we're not enabled.
if (!isEnabledForUrl)
return HUD.hide(true, false);
});
return function(frameIsFocused) {
if (frameIsFocused == null)
frameIsFocused = windowIsFocused();
return Frame.postMessage("isEnabledForUrl", {frameIsFocused, url: window.location.toString()});
};
})();
// When we're informed by the background page that a URL in this tab has changed, we check if we have the
// correct enabled state (but only if this frame has the focus).
var checkEnabledAfterURLChange = forTrusted(function() {
Scroller.reset(); // The URL changing feels like navigation to the user, so reset the scroller (see #3119).
if (windowIsFocused())
checkIfEnabledForUrl();
});
// If we are in the help dialog iframe, then HelpDialog is already defined with the necessary functions.
if (root.HelpDialog == null) {
root.HelpDialog = {
helpUI: null,
isShowing() {
return this.helpUI && this.helpUI.showing;
},
abort() {
if (this.isShowing())
return this.helpUI.hide(false);
},
toggle(request) {
DomUtils.documentComplete(() => {
if (!this.helpUI)
this.helpUI = new UIComponent("pages/help_dialog.html", "vimiumHelpDialogFrame", function() {});
return this.helpUI;
});
if ((this.helpUI != null) && this.isShowing())
return this.helpUI.hide();
else if (this.helpUI != null)
return this.helpUI.activate(Object.assign(request, {name: "activate", focus: true}));
}
};
}
initializePreDomReady();
DomUtils.documentReady(initializeOnDomReady);
Object.assign(root, {
handlerStack,
frameId,
Frame,
windowIsFocused,
bgLog,
// These are exported for normal mode and link-hints mode.
focusThisFrame,
// Exported only for tests.
installModes
});
Object.assign(window, root);

View file

@ -0,0 +1,84 @@
//
// This wraps the vomnibar iframe, which we inject into the page to provide the vomnibar.
//
const Vomnibar = {
vomnibarUI: null,
// Extract any additional options from the command's registry entry.
extractOptionsFromRegistryEntry(registryEntry, callback) {
return callback ? callback(Object.assign({}, registryEntry.options)) : null;
},
// sourceFrameId here (and below) is the ID of the frame from which this request originates, which may be
// different from the current frame.
activate(sourceFrameId, registryEntry) {
return this.extractOptionsFromRegistryEntry(registryEntry, options => {
return this.open(sourceFrameId, Object.assign(options, {completer:"omni"}));
});
},
activateInNewTab(sourceFrameId, registryEntry) {
return this.extractOptionsFromRegistryEntry(registryEntry, options => {
return this.open(sourceFrameId, Object.assign(options, {completer:"omni", newTab: true}));
});
},
activateTabSelection(sourceFrameId) {
return this.open(sourceFrameId, {
completer: "tabs",
selectFirst: true
});
},
activateBookmarks(sourceFrameId) {
return this.open(sourceFrameId, {
completer: "bookmarks",
selectFirst: true
});
},
activateBookmarksInNewTab(sourceFrameId) {
return this.open(sourceFrameId, {
completer: "bookmarks",
selectFirst: true,
newTab: true
});
},
activateEditUrl(sourceFrameId) {
return this.open(sourceFrameId, {
completer: "omni",
selectFirst: false,
query: window.location.href
});
},
activateEditUrlInNewTab(sourceFrameId) {
return this.open(sourceFrameId, {
completer: "omni",
selectFirst: false,
query: window.location.href,
newTab: true
});
},
init() {
if (!this.vomnibarUI)
this.vomnibarUI = new UIComponent("pages/vomnibar.html", "vomnibarFrame", function() {})
},
// This function opens the vomnibar. It accepts options, a map with the values:
// completer - The completer to fetch results from.
// query - Optional. Text to prefill the Vomnibar with.
// selectFirst - Optional, boolean. Whether to select the first entry.
// newTab - Optional, boolean. Whether to open the result in a new tab.
open(sourceFrameId, options) {
this.init();
// The Vomnibar cannot coexist with the help dialog (it causes focus issues).
HelpDialog.abort();
return this.vomnibarUI.activate(Object.assign(options, { name: "activate", sourceFrameId, focus: true }));
}
};
global.Vomnibar = Vomnibar;

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -0,0 +1,36 @@
const Clipboard = {
_createTextArea(tagName) {
if (tagName == null) { tagName = "textarea"; }
const textArea = document.createElement(tagName);
textArea.style.position = "absolute";
textArea.style.left = "-100%";
textArea.contentEditable = "true";
return textArea;
},
// http://groups.google.com/group/chromium-extensions/browse_thread/thread/49027e7f3b04f68/f6ab2457dee5bf55
copy({data}) {
const textArea = this._createTextArea();
textArea.value = data.replace(/\xa0/g, " ");
document.body.appendChild(textArea);
textArea.select();
document.execCommand("Copy");
document.body.removeChild(textArea);
},
// Returns a string representing the clipboard contents. Supports rich text clipboard values.
paste() {
const textArea = this._createTextArea("div"); // Use a <div> so Firefox pastes rich text.
document.body.appendChild(textArea);
textArea.focus();
document.execCommand("Paste");
const value = textArea.innerText;
document.body.removeChild(textArea);
// When copying &nbsp; characters, they get converted to \xa0. Convert to space instead. See #2217.
return value.replace(/\xa0/g, " ");
}
};
global.Clipboard = Clipboard;

View file

@ -0,0 +1,576 @@
var DomUtils = {
//
// Runs :callback if the DOM has loaded, otherwise runs it on load
//
documentReady: (function() {
let isReady = document.readyState !== "loading";
let callbacks = [];
if (!isReady) {
let onDOMContentLoaded;
window.addEventListener("DOMContentLoaded", (onDOMContentLoaded = forTrusted(function() {
window.removeEventListener("DOMContentLoaded", onDOMContentLoaded, true);
isReady = true;
for (let callback of callbacks) { callback(); }
callbacks = null;
})), true);
}
return function(callback) {
if (isReady) {
return callback();
} else {
callbacks.push(callback);
}
};
})(),
documentComplete: (function() {
let isComplete = document.readyState === "complete";
let callbacks = [];
if (!isComplete) {
let onLoad;
window.addEventListener("load", (onLoad = forTrusted(function(event) {
// The target is ensured to be on document. See https://w3c.github.io/uievents/#event-type-load
if (event.target !== document) { return; }
window.removeEventListener("load", onLoad, true);
isComplete = true;
for (let callback of callbacks) { callback(); }
callbacks = null;
})), true);
}
return function(callback) {
if (isComplete) {
callback();
} else {
callbacks.push(callback);
}
};
})(),
createElement(tagName) {
const element = document.createElement(tagName);
if (element instanceof HTMLElement) {
// The document namespace provides (X)HTML elements, so we can use them directly.
this.createElement = tagName => document.createElement(tagName);
return element;
} else {
// The document namespace doesn't give (X)HTML elements, so we create them with the correct namespace
// manually.
this.createElement = tagName => document.createElementNS("http://www.w3.org/1999/xhtml", tagName);
return this.createElement(tagName);
}
},
//
// Adds a list of elements to a page.
// Note that adding these nodes all at once (via the parent div) is significantly faster than one-by-one.
//
addElementList(els, overlayOptions) {
const parent = this.createElement("div");
if (overlayOptions.id != null) { parent.id = overlayOptions.id; }
if (overlayOptions.className != null) { parent.className = overlayOptions.className; }
for (let el of els) { parent.appendChild(el); }
document.documentElement.appendChild(parent);
return parent;
},
//
// Remove an element from its DOM tree.
//
removeElement(el) { return el.parentNode.removeChild(el); },
//
// Test whether the current frame is the top/main frame.
//
isTopFrame() {
return window.top === window.self;
},
//
// Takes an array of XPath selectors, adds the necessary namespaces (currently only XHTML), and applies them
// to the document root. The namespaceResolver in evaluateXPath should be kept in sync with the namespaces
// here.
//
makeXPath(elementArray) {
const xpath = [];
for (let element of elementArray) {
xpath.push(".//" + element, ".//xhtml:" + element);
}
return xpath.join(" | ");
},
// Evaluates an XPath on the whole document, or on the contents of the fullscreen element if an element is
// fullscreen.
evaluateXPath(xpath, resultType) {
const contextNode =
document.webkitIsFullScreen ? document.webkitFullscreenElement : document.documentElement;
const namespaceResolver = function(namespace) {
if (namespace === "xhtml") { return "http://www.w3.org/1999/xhtml"; } else { return null; }
};
return document.evaluate(xpath, contextNode, namespaceResolver, resultType, null);
},
//
// Returns the first visible clientRect of an element if it exists. Otherwise it returns null.
//
// WARNING: If testChildren = true then the rects of visible (eg. floated) children may be returned instead.
// This is used for LinkHints and focusInput, **BUT IS UNSUITABLE FOR MOST OTHER PURPOSES**.
//
getVisibleClientRect(element, testChildren) {
// Note: this call will be expensive if we modify the DOM in between calls.
let clientRect;
if (testChildren == null) { testChildren = false; }
const clientRects = ((() => {
const result = [];
for (clientRect of element.getClientRects())
result.push(Rect.copy(clientRect));
return result;
})());
// Inline elements with font-size: 0px; will declare a height of zero, even if a child with non-zero
// font-size contains text.
var isInlineZeroHeight = function() {
const elementComputedStyle = window.getComputedStyle(element, null);
const isInlineZeroFontSize = (0 === elementComputedStyle.getPropertyValue("display").indexOf("inline")) &&
(elementComputedStyle.getPropertyValue("font-size") === "0px");
// Override the function to return this value for the rest of this context.
isInlineZeroHeight = () => isInlineZeroFontSize;
return isInlineZeroFontSize;
};
for (clientRect of clientRects) {
// If the link has zero dimensions, it may be wrapping visible but floated elements. Check for this.
var computedStyle;
if (((clientRect.width === 0) || (clientRect.height === 0)) && testChildren) {
for (let child of Array.from(element.children)) {
var needle;
computedStyle = window.getComputedStyle(child, null);
// Ignore child elements which are not floated and not absolutely positioned for parent elements
// with zero width/height, as long as the case described at isInlineZeroHeight does not apply.
// NOTE(mrmr1993): This ignores floated/absolutely positioned descendants nested within inline
// children.
const position = computedStyle.getPropertyValue("position");
if ((computedStyle.getPropertyValue("float") === "none") &&
!((["absolute", "fixed"].includes(position))) &&
!((clientRect.height === 0) && isInlineZeroHeight() &&
(0 === computedStyle.getPropertyValue("display").indexOf("inline")))) {
continue;
}
const childClientRect = this.getVisibleClientRect(child, true);
if ((childClientRect === null) || (childClientRect.width < 3) || (childClientRect.height < 3)) { continue; }
return childClientRect;
}
} else {
clientRect = this.cropRectToVisible(clientRect);
if ((clientRect === null) || (clientRect.width < 3) || (clientRect.height < 3)) { continue; }
// eliminate invisible elements (see test_harnesses/visibility_test.html)
computedStyle = window.getComputedStyle(element, null);
if (computedStyle.getPropertyValue('visibility') !== 'visible') { continue; }
return clientRect;
}
}
return null;
},
//
// Bounds the rect by the current viewport dimensions. If the rect is offscreen or has a height or width < 3
// then null is returned instead of a rect.
//
cropRectToVisible(rect) {
const boundedRect = Rect.create(
Math.max(rect.left, 0),
Math.max(rect.top, 0),
rect.right,
rect.bottom
);
if ((boundedRect.top >= (window.innerHeight - 4)) || (boundedRect.left >= (window.innerWidth - 4))) {
return null;
} else {
return boundedRect;
}
},
//
// Get the client rects for the <area> elements in a <map> based on the position of the <img> element using
// the map. Returns an array of rects.
//
getClientRectsForAreas(imgClientRect, areas) {
const rects = [];
for (let area of areas) {
var x1, x2, y1, y2;
const coords = area.coords.split(",").map(coord => parseInt(coord, 10));
const shape = area.shape.toLowerCase();
if (["rect", "rectangle"].includes(shape)) { // "rectangle" is an IE non-standard.
[x1, y1, x2, y2] = coords;
} else if (["circle", "circ"].includes(shape)) { // "circ" is an IE non-standard.
const [x, y, r] = coords;
const diff = r / Math.sqrt(2); // Gives us an inner square
x1 = x - diff;
x2 = x + diff;
y1 = y - diff;
y2 = y + diff;
} else if (shape === "default") {
[x1, y1, x2, y2] = [0, 0, imgClientRect.width, imgClientRect.height];
} else {
// Just consider the rectangle surrounding the first two points in a polygon. It's possible to do
// something more sophisticated, but likely not worth the effort.
[x1, y1, x2, y2] = coords;
}
let rect = Rect.translate((Rect.create(x1, y1, x2, y2)), imgClientRect.left, imgClientRect.top);
rect = this.cropRectToVisible(rect);
if (rect && !isNaN(rect.top)) { rects.push({element: area, rect}); }
}
return rects;
},
//
// Selectable means that we should use the simulateSelect method to activate the element instead of a click.
//
// The html5 input types that should use simulateSelect are:
// ["date", "datetime", "datetime-local", "email", "month", "number", "password", "range", "search",
// "tel", "text", "time", "url", "week"]
// An unknown type will be treated the same as "text", in the same way that the browser does.
//
isSelectable(element) {
if (!(element instanceof Element)) { return false; }
const unselectableTypes = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "reset", "submit"];
return ((element.nodeName.toLowerCase() === "input") && (unselectableTypes.indexOf(element.type) === -1)) ||
(element.nodeName.toLowerCase() === "textarea") || element.isContentEditable;
},
// Input or text elements are considered focusable and able to receieve their own keyboard events, and will
// enter insert mode if focused. Also note that the "contentEditable" attribute can be set on any element
// which makes it a rich text editor, like the notes on jjot.com.
isEditable(element) {
return (this.isSelectable(element)) || ((element.nodeName != null ? element.nodeName.toLowerCase() : undefined) === "select");
},
// Embedded elements like Flash and quicktime players can obtain focus.
isEmbed(element) {
let nodeName = element.nodeName != null ? element.nodeName.toLowerCase() : null;
return ["embed", "object"].includes(nodeName);
},
isFocusable(element) {
return element && (this.isEditable(element) || this.isEmbed(element));
},
isDOMDescendant(parent, child) {
let node = child;
while (node !== null) {
if (node === parent) { return true; }
node = node.parentNode;
}
return false;
},
// True if element is editable and contains the active selection range.
isSelected(element) {
const selection = document.getSelection();
if (element.isContentEditable) {
const node = selection.anchorNode;
return node && this.isDOMDescendant(element, node);
} else {
if ((DomUtils.getSelectionType(selection) === "Range") && selection.isCollapsed) {
// The selection is inside the Shadow DOM of a node. We can check the node it registers as being
// before, since this represents the node whose Shadow DOM it's inside.
const containerNode = selection.anchorNode.childNodes[selection.anchorOffset];
return element === containerNode; // True if the selection is inside the Shadow DOM of our element.
} else {
return false;
}
}
},
simulateSelect(element) {
// If element is already active, then we don't move the selection. However, we also won't get a new focus
// event. So, instead we pretend (to any active modes which care, e.g. PostFindMode) that element has been
// clicked.
if ((element === document.activeElement) && DomUtils.isEditable(document.activeElement)) {
return handlerStack.bubbleEvent("click", {target: element});
} else {
element.focus();
if (element.tagName.toLowerCase() !== "textarea") {
// If the cursor is at the start of the (non-textarea) element's contents, send it to the end. Motivation:
// * the end is a more useful place to focus than the start,
// * this way preserves the last used position (except when it's at the beginning), so the user can
// 'resume where they left off'.
// NOTE(mrmr1993): Some elements throw an error when we try to access their selection properties, so
// wrap this with a try.
try {
if ((element.selectionStart === 0) && (element.selectionEnd === 0)) {
return element.setSelectionRange(element.value.length, element.value.length);
}
} catch (error) {}
}
}
},
simulateClick(element, modifiers) {
if (modifiers == null) { modifiers = {}; }
const eventSequence = ["mouseover", "mousedown", "mouseup", "click"];
const result = [];
for (let event of eventSequence) {
const defaultActionShouldTrigger =
Utils.isFirefox() && (Object.keys(modifiers).length === 0) && (event === "click") &&
(element.target === "_blank") && element.href &&
!element.hasAttribute("onclick") && !element.hasAttribute("_vimium-has-onclick-listener") ?
// Simulating a click on a target "_blank" element triggers the Firefox popup blocker.
// Note(smblott) This will be incorrect if there is a click listener on the element.
true
:
this.simulateMouseEvent(event, element, modifiers);
if ((event === "click") && defaultActionShouldTrigger && Utils.isFirefox()) {
// Firefox doesn't (currently) trigger the default action for modified keys.
if ((0 < Object.keys(modifiers).length) || (element.target === "_blank")) {
DomUtils.simulateClickDefaultAction(element, modifiers);
}
}
result.push(defaultActionShouldTrigger);
} // return the values returned by each @simulateMouseEvent call.
return result;
},
simulateMouseEvent(event, element, modifiers) {
if (modifiers == null) { modifiers = {}; }
if (event === "mouseout") {
// Allow unhovering the last hovered element by passing undefined.
if (element == null) { element = this.lastHoveredElement; }
this.lastHoveredElement = undefined;
if (element == null) { return; }
} else if (event === "mouseover") {
// Simulate moving the mouse off the previous element first, as if we were a real mouse.
this.simulateMouseEvent("mouseout", undefined, modifiers);
this.lastHoveredElement = element;
}
const mouseEvent = new MouseEvent(event, {
bubbles: true,
cancelable: true,
composed: true,
view: window,
detail: 1,
ctrlKey: modifiers.ctrlKey,
altKey: modifiers.altKey,
shiftKey: modifiers.shiftKey,
metaKey: modifiers.metaKey
});
// Debugging note: Firefox will not execute the element's default action if we dispatch this click event,
// but Webkit will. Dispatching a click on an input box does not seem to focus it; we do that separately
return element.dispatchEvent(mouseEvent);
},
simulateClickDefaultAction(element, modifiers) {
let newTabModifier;
if (modifiers == null) { modifiers = {}; }
if (((element.tagName != null ? element.tagName.toLowerCase() : undefined) !== "a") || !element.href) { return; }
const {ctrlKey, shiftKey, metaKey, altKey} = modifiers;
// Mac uses a different new tab modifier (meta vs. ctrl).
if (KeyboardUtils.platform === "Mac") {
newTabModifier = (metaKey === true) && (ctrlKey === false);
} else {
newTabModifier = (metaKey === false) && (ctrlKey === true);
}
if (newTabModifier) {
// Open in new tab. Shift determines whether the tab is focused when created. Alt is ignored.
chrome.runtime.sendMessage({handler: "openUrlInNewTab", url: element.href, active:
shiftKey === true});
} else if ((shiftKey === true) && (metaKey === false) && (ctrlKey === false) && (altKey === false)) {
// Open in new window.
chrome.runtime.sendMessage({handler: "openUrlInNewWindow", url: element.href});
} else if (element.target === "_blank") {
chrome.runtime.sendMessage({handler: "openUrlInNewTab", url: element.href, active: true});
}
},
simulateHover(element, modifiers) {
if (modifiers == null) { modifiers = {}; }
return this.simulateMouseEvent("mouseover", element, modifiers);
},
simulateUnhover(element, modifiers) {
if (modifiers == null) { modifiers = {}; }
return this.simulateMouseEvent("mouseout", element, modifiers);
},
addFlashRect(rect) {
const flashEl = this.createElement("div");
flashEl.classList.add("vimiumReset");
flashEl.classList.add("vimiumFlash");
flashEl.style.left = rect.left + "px";
flashEl.style.top = rect.top + "px";
flashEl.style.width = rect.width + "px";
flashEl.style.height = rect.height + "px";
document.documentElement.appendChild(flashEl);
return flashEl;
},
getViewportTopLeft() {
const box = document.documentElement;
const style = getComputedStyle(box);
const rect = box.getBoundingClientRect();
if ((style.position === "static") && !/content|paint|strict/.test(style.contain || "")) {
// The margin is included in the client rect, so we need to subtract it back out.
const marginTop = parseInt(style.marginTop);
const marginLeft = parseInt(style.marginLeft);
return {top: -rect.top + marginTop, left: -rect.left + marginLeft};
} else {
let clientLeft, clientTop;
if (Utils.isFirefox()) {
// These are always 0 for documentElement on Firefox, so we derive them from CSS border.
clientTop = parseInt(style.borderTopWidth);
clientLeft = parseInt(style.borderLeftWidth);
} else {
({clientTop, clientLeft} = box);
}
return {top: -rect.top - clientTop, left: -rect.left - clientLeft};
}
},
suppressPropagation(event) {
event.stopImmediatePropagation();
},
suppressEvent(event) {
event.preventDefault();
this.suppressPropagation(event);
},
consumeKeyup: (function() {
let handlerId = null;
return function(event, callback = null, suppressPropagation) {
if (!event.repeat) {
if (handlerId != null) { handlerStack.remove(handlerId); }
const {
code
} = event;
handlerId = handlerStack.push({
_name: "dom_utils/consumeKeyup",
keyup(event) {
if (event.code !== code) { return handlerStack.continueBubbling; }
this.remove();
if (suppressPropagation) {
DomUtils.suppressPropagation(event);
} else {
DomUtils.suppressEvent(event);
}
return handlerStack.continueBubbling;
},
// We cannot track keyup events if we lose the focus.
blur(event) {
if (event.target === window) { this.remove(); }
return handlerStack.continueBubbling;
}
});
}
if (typeof callback === 'function') {
callback();
}
if (suppressPropagation) {
DomUtils.suppressPropagation(event);
return handlerStack.suppressPropagation;
} else {
DomUtils.suppressEvent(event);
return handlerStack.suppressEvent;
}
};
})(),
// Polyfill for selection.type (which is not available in Firefox).
getSelectionType(selection) {
if (selection == null) { selection = document.getSelection(); }
if (selection.type) {
return selection.type;
} else if (selection.rangeCount === 0) {
return "None";
} else if (selection.isCollapsed) {
return "Caret";
} else {
return "Range";
}
},
// Adapted from: http://roysharon.com/blog/37.
// This finds the element containing the selection focus.
getElementWithFocus(selection, backwards) {
let t;
let r = (t = selection.getRangeAt(0));
if (DomUtils.getSelectionType(selection) === "Range") {
r = t.cloneRange();
r.collapse(backwards);
}
t = r.startContainer;
if (t.nodeType === 1) { t = t.childNodes[r.startOffset]; }
let o = t;
while (o && (o.nodeType !== 1)) { o = o.previousSibling; }
t = o || (t != null ? t.parentNode : undefined);
return t;
},
getSelectionFocusElement() {
const sel = window.getSelection();
let node = sel.focusNode;
if ((node == null)) {
return null;
}
if ((node === sel.anchorNode) && (sel.focusOffset === sel.anchorOffset)) {
// If the selection is not a caret inside a `#text`, which has no child nodes,
// then it either *is* an element, or is inside an opaque element (eg. <input>).
node = node.childNodes[sel.focusOffset] || node;
}
if (node.nodeType !== Node.ELEMENT_NODE) { return node.parentElement; } else { return node; }
},
// Get the element in the DOM hierachy that contains `element`.
// If the element is rendered in a shadow DOM via a <content> element, the <content> element will be
// returned, so the shadow DOM is traversed rather than passed over.
getContainingElement(element) {
return (typeof element.getDestinationInsertionPoints === 'function' ? element.getDestinationInsertionPoints()[0] : undefined) || element.parentElement;
},
// This tests whether a window is too small to be useful.
windowIsTooSmall() {
return (window.innerWidth < 3) || (window.innerHeight < 3);
},
// Inject user styles manually. This is only necessary for our chrome-extension:// pages and frames.
injectUserCss() {
Settings.onLoaded(function() {
const style = document.createElement("style");
style.type = "text/css";
style.textContent = Settings.get("userDefinedLinkHintCss");
document.head.appendChild(style);
});
},
// Inject user Javascript.
injectUserScript(text) {
if (text.slice(0, 11) === "javascript:") {
text = text.slice(11).trim();
if (text.indexOf(" ") < 0) {
try { text = decodeURIComponent(text); } catch (error) {}
}
}
const script = document.createElement("script");
script.textContent = text;
return document.head.appendChild(script);
}
};
global.DomUtils = DomUtils;

View file

@ -0,0 +1,67 @@
// NOTE(mrmr1993): This is under lib/ since it is used by both content scripts and iframes from pages/.
// This implements find-mode query history (using the "findModeRawQueryList" setting) as a list of raw queries,
// most recent first.
const FindModeHistory = {
storage: (typeof chrome !== 'undefined' && chrome !== null ? chrome.storage.local : undefined), // Guard against chrome being undefined (in the HUD iframe).
key: "findModeRawQueryList",
max: 50,
rawQueryList: null,
init() {
this.isIncognitoMode = typeof chrome !== 'undefined' && chrome !== null ? chrome.extension.inIncognitoContext : undefined;
if (this.isIncognitoMode == null) { return; } // chrome is undefined in the HUD iframe during tests, so we do nothing.
if (!this.rawQueryList) {
this.rawQueryList = []; // Prevent repeated initialization.
if (this.isIncognitoMode) { this.key = "findModeRawQueryListIncognito"; }
this.storage.get(this.key, items => {
if (!chrome.runtime.lastError) {
if (items[this.key]) { this.rawQueryList = items[this.key]; }
if (this.isIncognitoMode && !items[this.key]) {
// This is the first incognito tab, so we need to initialize the incognito-mode query history.
this.storage.get("findModeRawQueryList", items => {
if (!chrome.runtime.lastError) {
this.rawQueryList = items.findModeRawQueryList;
this.storage.set({findModeRawQueryListIncognito: this.rawQueryList});
}
});
}
}
});
}
chrome.storage.onChanged.addListener((changes, area) => {
if (changes[this.key]) { this.rawQueryList = changes[this.key].newValue; }
});
},
getQuery(index) {
if (index == null) { index = 0; }
return this.rawQueryList[index] || "";
},
saveQuery(query) {
if (0 < query.length) {
this.rawQueryList = this.refreshRawQueryList(query, this.rawQueryList);
const newSetting = {};
newSetting[this.key] = this.rawQueryList;
this.storage.set(newSetting);
// If there are any active incognito-mode tabs, then propagte this query to those tabs too.
if (!this.isIncognitoMode) {
this.storage.get("findModeRawQueryListIncognito", items => {
if (!chrome.runtime.lastError && items.findModeRawQueryListIncognito) {
this.storage.set({
findModeRawQueryListIncognito: this.refreshRawQueryList(query, items.findModeRawQueryListIncognito)});
}
});
}
}
},
refreshRawQueryList(query, rawQueryList) {
return ([query].concat(rawQueryList.filter(q => q !== query))).slice(0, this.max + 1);
}
};
global.FindModeHistory = FindModeHistory;

View file

@ -0,0 +1,157 @@
class HandlerStack {
constructor() {
this.debug = false;
this.eventNumber = 0;
this.stack = [];
this.counter = 0;
// A handler should return this value to immediately discontinue bubbling and pass the event on to the
// underlying page.
this.passEventToPage = new Object();
// A handler should return this value to indicate that the event has been consumed, and no further
// processing should take place. The event does not propagate to the underlying page.
this.suppressPropagation = new Object();
// A handler should return this value to indicate that bubbling should be restarted. Typically, this is
// used when, while bubbling an event, a new mode is pushed onto the stack.
this.restartBubbling = new Object();
// A handler should return this value to continue bubbling the event.
this.continueBubbling = true;
// A handler should return this value to suppress an event.
this.suppressEvent = false;
}
// Adds a handler to the top of the stack. Returns a unique ID for that handler that can be used to remove it
// later.
push(handler) {
if (!handler._name) { handler._name = `anon-${this.counter}`; }
this.stack.push(handler);
return handler.id = ++this.counter;
}
// As above, except the new handler is added to the bottom of the stack.
unshift(handler) {
if (!handler._name) { handler._name = `anon-${this.counter}`; }
handler._name += "/unshift";
this.stack.unshift(handler);
return handler.id = ++this.counter;
}
// Called whenever we receive a key or other event. Each individual handler has the option to stop the
// event's propagation by returning a falsy value, or stop bubbling by returning @suppressPropagation or
// @passEventToPage.
bubbleEvent(type, event) {
this.eventNumber += 1;
const {
eventNumber
} = this;
for (let handler of this.stack.slice().reverse()) {
// A handler might have been removed (handler.id == null), so check; or there might just be no handler
// for this type of event.
if (!(handler != null ? handler.id : undefined) || !handler[type]) {
if (this.debug) { this.logResult(eventNumber, type, event, handler, `skip [${(handler[type] != null)}]`); }
} else {
this.currentId = handler.id;
const result = handler[type].call(this, event);
if (this.debug) { this.logResult(eventNumber, type, event, handler, result); }
if (result === this.passEventToPage) {
return true;
} else if (result === this.suppressPropagation) {
if (type === "keydown") {
DomUtils.consumeKeyup(event, null, true);
} else {
DomUtils.suppressPropagation(event);
}
return false;
} else if (result === this.restartBubbling) {
return this.bubbleEvent(type, event);
} else if ((result === this.continueBubbling) || (result && (result !== this.suppressEvent))) {
true; // Do nothing, but continue bubbling.
} else {
// result is @suppressEvent or falsy.
if (this.isChromeEvent(event)) {
if (type === "keydown") {
DomUtils.consumeKeyup(event);
} else {
DomUtils.suppressEvent(event);
}
}
return false;
}
}
}
// None of our handlers care about this event, so pass it to the page.
return true;
}
remove(id) {
if (id == null) { id = this.currentId; }
for (let i = this.stack.length - 1; i >= 0; i--) {
const handler = this.stack[i];
if (handler.id === id) {
// Mark the handler as removed.
handler.id = null;
this.stack.splice(i, 1);
break;
}
}
}
// The handler stack handles chrome events (which may need to be suppressed) and internal (pseudo) events.
// This checks whether the event at hand is a chrome event.
isChromeEvent(event) {
// TODO(philc): Shorten this.
return ((event != null ? event.preventDefault : undefined) != null) || ((event != null ? event.stopImmediatePropagation : undefined) != null);
}
// Convenience wrappers. Handlers must return an approriate value. These are wrappers which handlers can
// use to always return the same value. This then means that the handler itself can be implemented without
// regard to its return value.
alwaysContinueBubbling(handler = null) {
if (typeof handler === 'function') {
handler();
}
return this.continueBubbling;
}
alwaysSuppressPropagation(handler = null) {
// TODO(philc): Shorten this.
if ((typeof handler === 'function' ? handler() : undefined) === this.suppressEvent) { return this.suppressEvent; } else { return this.suppressPropagation; }
}
// Debugging.
logResult(eventNumber, type, event, handler, result) {
if ((event != null ? event.type : undefined) === "keydown") { // Tweak this as needed.
let label =
(() => { switch (result) {
case this.passEventToPage: return "passEventToPage";
case this.suppressEvent: return "suppressEvent";
case this.suppressPropagation: return "suppressPropagation";
case this.restartBubbling: return "restartBubbling";
case "skip": return "skip";
case true: return "continue";
} })();
if (!label) { label = result ? "continue/truthy" : "suppress"; }
console.log(`${eventNumber}`, type, handler._name, label);
}
}
show() {
console.log(`${this.eventNumber}:`);
for (let handler of this.stack.slice().reverse()) {
console.log(" ", handler._name);
}
}
// For tests only.
reset() {
this.stack = [];
}
}
global.HandlerStack = HandlerStack;
global.handlerStack = new HandlerStack();

View file

@ -0,0 +1,131 @@
let mapKeyRegistry = {};
Utils.monitorChromeStorage("mapKeyRegistry", value => { return mapKeyRegistry = value; });
const KeyboardUtils = {
// This maps event.key key names to Vimium key names.
keyNames: {
"ArrowLeft": "left", "ArrowUp": "up", "ArrowRight": "right", "ArrowDown": "down", " ": "space"
},
init() {
if (navigator.userAgent.indexOf("Mac") !== -1) {
this.platform = "Mac";
} else if (navigator.userAgent.indexOf("Linux") !== -1) {
this.platform = "Linux";
} else {
this.platform = "Windows";
}
},
getKeyChar(event) {
let key;
if (!Settings.get("ignoreKeyboardLayout")) {
key = event.key;
} else if (!event.code) {
key = event.key != null ? event.key : ""; // Fall back to event.key (see #3099).
} else if (event.code.slice(0, 6) === "Numpad") {
// We cannot correctly emulate the numpad, so fall back to event.key; see #2626.
key = event.key;
} else {
// The logic here is from the vim-like-key-notation project (https://github.com/lydell/vim-like-key-notation).
key = event.code;
if (key.slice(0, 3) === "Key") { key = key.slice(3); }
// Translate some special keys to event.key-like strings and handle <Shift>.
if (this.enUsTranslations[key]) {
key = event.shiftKey ? this.enUsTranslations[key][1] : this.enUsTranslations[key][0];
} else if ((key.length === 1) && !event.shiftKey) {
key = key.toLowerCase();
}
}
// It appears that key is not always defined (see #2453).
if (!key) {
return "";
} else if (key in this.keyNames) {
return this.keyNames[key];
} else if (this.isModifier(event)) {
return ""; // Don't resolve modifier keys.
} else if (key.length === 1) {
return key;
} else {
return key.toLowerCase();
}
},
getKeyCharString(event) {
let keyChar = this.getKeyChar(event);
if (!keyChar)
return;
const modifiers = [];
if (event.shiftKey && (keyChar.length === 1)) { keyChar = keyChar.toUpperCase(); }
// These must be in alphabetical order (to match the sorted modifier order in Commands.normalizeKey).
if (event.altKey) { modifiers.push("a"); }
if (event.ctrlKey) { modifiers.push("c"); }
if (event.metaKey) { modifiers.push("m"); }
if (event.shiftKey && (keyChar.length > 1)) { modifiers.push("s"); }
keyChar = [...modifiers, keyChar].join("-");
if (1 < keyChar.length) { keyChar = `<${keyChar}>`; }
keyChar = mapKeyRegistry[keyChar] != null ? mapKeyRegistry[keyChar] : keyChar;
return keyChar;
},
isEscape: (function() {
let useVimLikeEscape = true;
Utils.monitorChromeStorage("useVimLikeEscape", value => useVimLikeEscape = value);
return function(event) {
// <c-[> is mapped to Escape in Vim by default.
// Escape with a keyCode 229 means that this event comes from IME, and should not be treated as a
// direct/normal Escape event. IME will handle the event, not vimium.
// See https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html
return ((event.key === "Escape") && (event.keyCode !== 229)) ||
(useVimLikeEscape && (this.getKeyCharString(event) === "<c-[>"));
};
})(),
isBackspace(event) {
return ["Backspace", "Delete"].includes(event.key);
},
isPrintable(event) {
const s = this.getKeyCharString(event);
return s && s.length == 1;
},
isModifier(event) {
return ["Control", "Shift", "Alt", "OS", "AltGraph", "Meta"].includes(event.key);
},
enUsTranslations: {
"Backquote": ["`", "~"],
"Minus": ["-", "_"],
"Equal": ["=", "+"],
"Backslash": ["\\","|"],
"IntlBackslash": ["\\","|"],
"BracketLeft": ["[", "{"],
"BracketRight": ["]", "}"],
"Semicolon": [";", ":"],
"Quote": ["'", '"'],
"Comma": [",", "<"],
"Period": [".", ">"],
"Slash": ["/", "?"],
"Space": [" ", " "],
"Digit1": ["1", "!"],
"Digit2": ["2", "@"],
"Digit3": ["3", "#"],
"Digit4": ["4", "$"],
"Digit5": ["5", "%"],
"Digit6": ["6", "^"],
"Digit7": ["7", "&"],
"Digit8": ["8", "*"],
"Digit9": ["9", "("],
"Digit0": ["0", ")"]
}
};
KeyboardUtils.init();
global.KeyboardUtils = KeyboardUtils;

View file

@ -0,0 +1,109 @@
// Commands for manipulating rects.
var Rect = {
// Create a rect given the top left and bottom right corners.
create(x1, y1, x2, y2) {
return {
bottom: y2,
top: y1,
left: x1,
right: x2,
width: x2 - x1,
height: y2 - y1
};
},
copy(rect) {
return {
bottom: rect.bottom,
top: rect.top,
left: rect.left,
right: rect.right,
width: rect.width,
height: rect.height
};
},
// Translate a rect by x horizontally and y vertically.
translate(rect, x, y) {
if (x == null) { x = 0; }
if (y == null) { y = 0; }
return {
bottom: rect.bottom + y,
top: rect.top + y,
left: rect.left + x,
right: rect.right + x,
width: rect.width,
height: rect.height
};
},
// Subtract rect2 from rect1, returning an array of rects which are in rect1 but not rect2.
subtract(rect1, rect2) {
// Bound rect2 by rect1
rect2 = this.create(
Math.max(rect1.left, rect2.left),
Math.max(rect1.top, rect2.top),
Math.min(rect1.right, rect2.right),
Math.min(rect1.bottom, rect2.bottom)
);
// If bounding rect2 has made the width or height negative, rect1 does not contain rect2.
if ((rect2.width < 0) || (rect2.height < 0)) { return [Rect.copy(rect1)]; }
//
// All the possible rects, in the order
// +-+-+-+
// |1|2|3|
// +-+-+-+
// |4| |5|
// +-+-+-+
// |6|7|8|
// +-+-+-+
// where the outer rectangle is rect1 and the inner rectangle is rect 2. Note that the rects may be of
// width or height 0.
//
const rects = [
// Top row.
this.create(rect1.left, rect1.top, rect2.left, rect2.top),
this.create(rect2.left, rect1.top, rect2.right, rect2.top),
this.create(rect2.right, rect1.top, rect1.right, rect2.top),
// Middle row.
this.create(rect1.left, rect2.top, rect2.left, rect2.bottom),
this.create(rect2.right, rect2.top, rect1.right, rect2.bottom),
// Bottom row.
this.create(rect1.left, rect2.bottom, rect2.left, rect1.bottom),
this.create(rect2.left, rect2.bottom, rect2.right, rect1.bottom),
this.create(rect2.right, rect2.bottom, rect1.right, rect1.bottom)
];
return rects.filter(rect => (rect.height > 0) && (rect.width > 0));
},
// Determine whether two rects overlap.
intersects(rect1, rect2) {
return (rect1.right > rect2.left) &&
(rect1.left < rect2.right) &&
(rect1.bottom > rect2.top) &&
(rect1.top < rect2.bottom);
},
// Determine whether two rects overlap, including 0-width intersections at borders.
intersectsStrict(rect1, rect2) {
return (rect1.right >= rect2.left) && (rect1.left <= rect2.right) &&
(rect1.bottom >= rect2.top) && (rect1.top <= rect2.bottom);
},
equals(rect1, rect2) {
for (let property of ["top", "bottom", "left", "right", "width", "height"]) {
if (rect1[property] !== rect2[property]) { return false; }
}
return true;
},
intersect(rect1, rect2) {
return this.create((Math.max(rect1.left, rect2.left)), (Math.max(rect1.top, rect2.top)),
(Math.min(rect1.right, rect2.right)), (Math.min(rect1.bottom, rect2.bottom)));
}
};
global.Rect = Rect;

View file

@ -0,0 +1,279 @@
// A "setting" is a stored key/value pair. An "option" is a setting which has a default value and whose value
// can be changed on the options page.
//
// Option values which have never been changed by the user are in Settings.defaults.
//
// Settings whose values have been changed are:
// 1. stored either in chrome.storage.sync or in chrome.storage.local (but never both), and
// 2. cached in Settings.cache; on extension pages, Settings.cache uses localStorage (so it persists).
//
// In all cases except Settings.defaults, values are stored as jsonified strings.
// If the current frame is the Vomnibar or the HUD, then we'll need our Chrome stubs for the tests.
// We use "try" because this fails within iframes on Firefox (where failure doesn't actually matter).
try { if (window.chrome == null) { window.chrome = window.top != null ? window.top.chrome : undefined; } } catch (error) {}
let storageArea = (chrome.storage.sync != null) ? "sync" : "local";
const Settings = {
debug: false,
storage: chrome.storage[storageArea],
cache: {},
isLoaded: false,
onLoadedCallbacks: [],
init() {
if (Utils.isExtensionPage() && Utils.isExtensionPage(window.top)) {
// On extension pages, we use localStorage (or a copy of it) as the cache.
// For UIComponents (or other content of ours in an iframe within a regular page), we can't access
// localStorage, so we check that the top level frame is also an extension page.
this.cache = Utils.isBackgroundPage() ?
localStorage :
Object.assign({}, localStorage);
this.runOnLoadedCallbacks();
}
// Test chrome.storage.sync to see if it is enabled.
// NOTE(mrmr1993, 2017-04-18): currently the API is defined in FF, but it is disabled behind a flag in
// about:config. Every use sets chrome.runtime.lastError, so we use that to check whether we can use it.
chrome.storage.sync.get(null, () => {
if (chrome.runtime.lastError) {
storageArea = "local";
this.storage = chrome.storage[storageArea];
}
// Delay this initialisation until after the correct storage area is known. The significance of this is
// that it delays the on-loaded callbacks.
chrome.storage.local.get(null, localItems => {
if (chrome.runtime.lastError) { localItems = {}; }
return this.storage.get(null, syncedItems => {
if (!chrome.runtime.lastError) {
// TODO(philc): I think localItems can only be null in tests.
const object = Object.assign(localItems || {}, syncedItems);
for (let key of Object.keys(object)) {
const value = object[key];
this.handleUpdateFromChromeStorage(key, value);
}
}
chrome.storage.onChanged.addListener((changes, area) => {
if (area === storageArea) { return this.propagateChangesFromChromeStorage(changes); }
});
this.runOnLoadedCallbacks();
});
});
});
},
// Called after @cache has been initialized. On extension pages, this will be called twice, but that does
// not matter because it's idempotent.
runOnLoadedCallbacks() {
this.log(`runOnLoadedCallbacks: ${this.onLoadedCallbacks.length} callback(s)`);
this.isLoaded = true;
while (0 < this.onLoadedCallbacks.length) { this.onLoadedCallbacks.pop()(); }
},
// Returns the value of callback if it can be executed immediately.
// TODO(philc): This return value behavior is strange. Ideally this returns nil, as you would expect from a
// potentially async function.
onLoaded(callback) {
if (this.isLoaded) {
return callback();
} else {
this.onLoadedCallbacks.push(callback);
}
},
shouldSyncKey(key) {
return (key in this.defaults) && !["settingsVersion", "previousVersion"].includes(key);
},
propagateChangesFromChromeStorage(changes) {
for (let key of Object.keys(changes || {})) {
const change = changes[key];
this.handleUpdateFromChromeStorage(key, change != null ? change.newValue : undefined);
}
},
handleUpdateFromChromeStorage(key, value) {
this.log(`handleUpdateFromChromeStorage: ${key}`);
// Note: value here is either null or a JSONified string. Therefore, even falsy settings values (like
// false, 0 or "") are truthy here. Only null is falsy.
if (this.shouldSyncKey(key)) {
if (!value || !(key in this.cache) || (this.cache[key] !== value)) {
if (value == null) { value = JSON.stringify(this.defaults[key]); }
this.set(key, JSON.parse(value), false);
}
}
},
get(key) {
if (!this.isLoaded)
console.log(`WARNING: Settings have not loaded yet; using the default value for ${key}.`);
if (key in this.cache && (this.cache[key] != null))
return JSON.parse(this.cache[key]);
else
return this.defaults[key];
},
set(key, value, shouldSetInSyncedStorage) {
if (shouldSetInSyncedStorage == null) { shouldSetInSyncedStorage = true; }
this.cache[key] = JSON.stringify(value);
this.log(`set: ${key} (length=${this.cache[key].length}, shouldSetInSyncedStorage=${shouldSetInSyncedStorage})`);
if (this.shouldSyncKey(key)) {
if (shouldSetInSyncedStorage) {
const setting = {}; setting[key] = this.cache[key];
this.log(` chrome.storage.${storageArea}.set(${key})`);
this.storage.set(setting);
}
if (Utils.isBackgroundPage() && (storageArea === "sync")) {
// Remove options installed by the "copyNonDefaultsToChromeStorage-20150717" migration; see below.
this.log(` chrome.storage.local.remove(${key})`);
chrome.storage.local.remove(key);
}
}
// NOTE(mrmr1993): In FF, |value| will be garbage collected when the page owning it is unloaded.
// Any postUpdateHooks that can be called from the options page/exclusions popup should be careful not to
// use |value| asynchronously, or else it may refer to a |DeadObject| and accesses will throw an error.
this.performPostUpdateHook(key, value);
},
clear(key) {
this.log(`clear: ${key}`);
this.set(key, this.defaults[key]);
},
has(key) { return key in this.cache; },
use(key, callback) {
this.log(`use: ${key} (isLoaded=${this.isLoaded})`);
this.onLoaded(() => callback(this.get(key)));
},
// For settings which require action when their value changes, add hooks to this object.
postUpdateHooks: {},
performPostUpdateHook(key, value) {
if (this.postUpdateHooks[key])
this.postUpdateHooks[key](value);
},
// Completely remove a settings value, e.g. after migration to a new setting. This should probably only be
// called from the background page.
nuke(key) {
delete localStorage[key];
chrome.storage.local.remove(key);
if (chrome.storage.sync != null) {
chrome.storage.sync.remove(key);
}
},
// For development only.
log(...args) {
if (this.debug) { console.log("settings:", ...args); }
},
// Default values for all settings.
defaults: {
scrollStepSize: 60,
smoothScroll: true,
keyMappings: "# Insert your preferred key mappings here.",
linkHintCharacters: "sadfjklewcmpgh",
linkHintNumbers: "0123456789",
filterLinkHints: false,
hideHud: false,
userDefinedLinkHintCss:
`\
div > .vimiumHintMarker {
/* linkhint boxes */
background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785),
color-stop(100%,#FFC542));
border: 1px solid #E3BE23;
}
div > .vimiumHintMarker span {
/* linkhint text */
color: black;
font-weight: bold;
font-size: 12px;
}
div > .vimiumHintMarker > .matchingCharacter {
}\
`,
// Default exclusion rules.
exclusionRules:
[
// Disable Vimium on Gmail.
{ pattern: "https?://mail.google.com/*", passKeys: "" }
],
// NOTE: If a page contains both a single angle-bracket link and a double angle-bracket link, then in
// most cases the single bracket link will be "prev/next page" and the double bracket link will be
// "first/last page", so we put the single bracket first in the pattern string so that it gets searched
// for first.
// "\bprev\b,\bprevious\b,\bback\b,<,,←,«,≪,<<"
previousPatterns: "prev,previous,back,older,<,\u2039,\u2190,\xab,\u226a,<<",
// "\bnext\b,\bmore\b,>,,→,»,≫,>>"
nextPatterns: "next,more,newer,>,\u203a,\u2192,\xbb,\u226b,>>",
// default/fall back search engine
searchUrl: "https://www.google.com/search?q=",
// put in an example search engine
searchEngines:
`\
w: https://www.wikipedia.org/w/index.php?title=Special:Search&search=%s Wikipedia
# More examples.
#
# (Vimium supports search completion Wikipedia, as
# above, and for these.)
#
# g: https://www.google.com/search?q=%s Google
# l: https://www.google.com/search?q=%s&btnI I'm feeling lucky...
# y: https://www.youtube.com/results?search_query=%s Youtube
# gm: https://www.google.com/maps?q=%s Google maps
# b: https://www.bing.com/search?q=%s Bing
# d: https://duckduckgo.com/?q=%s DuckDuckGo
# az: https://www.amazon.com/s/?field-keywords=%s Amazon
# qw: https://www.qwant.com/?q=%s Qwant\
`,
newTabUrl: "about:newtab",
grabBackFocus: false,
regexFindMode: false,
waitForEnterForFilteredHints: false, // Note: this defaults to true for new users; see below.
settingsVersion: "",
helpDialog_showAdvancedCommands: false,
optionsPage_showAdvancedOptions: false,
passNextKeyKeys: [],
ignoreKeyboardLayout: false
}
};
Settings.init();
// Perform migration from old settings versions, if this is the background page.
if (Utils.isBackgroundPage()) {
Settings.applyMigrations = function() {
if (!Settings.get("settingsVersion")) {
// This is a new install. For some settings, we retain a legacy default behaviour for existing users but
// use a non-default behaviour for new users.
// For waitForEnterForFilteredHints, "true" gives a better UX; see #1950. However, forcing the change on
// existing users would be unnecessarily disruptive. So, only new users default to "true".
Settings.set("waitForEnterForFilteredHints", true);
}
// We use settingsVersion to coordinate any necessary schema changes.
Settings.set("settingsVersion", Utils.getCurrentVersion());
// Remove legacy key which was used to control storage migration. This was after 1.57 (2016-10-01), and
// can be removed after 1.58 has been out for sufficiently long.
Settings.nuke("copyNonDefaultsToChromeStorage-20150717");
};
Settings.onLoaded(Settings.applyMigrations.bind(Settings));
}
global.Settings = Settings;

View file

@ -0,0 +1,427 @@
// utils.js is the first file required by our background scripts and content scripts. When running tests, the
// environment is nodejs, which has a `global` object that defines the global scope. In the browser, that
// global scope is `window`. The rest of our code attaches exported functions into this global scope.
// We don't use ES modules because they don't work in Chrome extension content scripts.
if (typeof(global) == "undefined")
global = window;
// Only pass events to the handler if they are marked as trusted by the browser.
// This is kept in the global namespace for brevity and ease of use.
if (window.forTrusted == null) {
window.forTrusted = handler => (function(event) {
if (event && event.isTrusted) {
return handler.apply(this, arguments);
} else {
return true;
}
});
}
// TODO(philc): The comment below about its usage doesn't make sense to me. Can we replace this with
// window.navigator?
let browserInfo = null;
if (window.browser && browser.runtime && browser.runtime.getBrowserInfo)
browserInfo = browser.runtime.getBrowserInfo();
var Utils = {
isFirefox: (function() {
// We want this browser check to also cover Firefox variants, like LibreWolf. See #3773.
const isFirefox = typeof InstallTrigger !== 'undefined';
return () => isFirefox;
})(),
firefoxVersion: (function() {
// NOTE(mrmr1993): This only works in the background page.
let ffVersion = undefined;
if (browserInfo) {
browserInfo.then(browserInfo => ffVersion = browserInfo != null ? browserInfo.version : undefined);
}
return () => ffVersion;
})(),
getCurrentVersion() {
return chrome.runtime.getManifest().version;
},
// Returns true whenever the current page (or the page supplied as an argument) is from the extension's
// origin (and thus can access the extension's localStorage).
isExtensionPage(win) {
if (win == null) { win = window; }
try {
return ((win.document.location != null ? win.document.location.origin : undefined) + "/")
=== chrome.extension.getURL("");
} catch (error) {}
},
// Returns true whenever the current page is the extension's background page.
isBackgroundPage() {
// NOTE(philc): chrome.extension.getBackgroundPage is undefined under some circumstances, but I wasn't
// able to determine precisely which.
return this.isBackgroundPage && chrome.extension.getBackgroundPage &&
chrome.extension.getBackgroundPage() === window;
},
// Escape all special characters, so RegExp will parse the string 'as is'.
// Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
escapeRegexSpecialCharacters: (function() {
const escapeRegex = /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g;
return str => str.replace(escapeRegex, "\\$&");
})(),
escapeHtml(string) { return string.replace(/</g, "&lt;").replace(/>/g, "&gt;"); },
// Generates a unique ID
createUniqueId: (function() {
let id = 0;
return () => id += 1;
})(),
hasChromePrefix: (function() {
const chromePrefixes = ["about:", "view-source:", "extension:", "chrome-extension:", "data:"];
return url => chromePrefixes.some(prefix => url.startsWith(prefix));
})(),
hasJavascriptPrefix(url) {
return url.startsWith("javascript:");
},
hasFullUrlPrefix: (function() {
const urlPrefix = new RegExp("^[a-z][-+.a-z0-9]{2,}://.");
return url => urlPrefix.test(url);
})(),
// Decode valid escape sequences in a URI. This is intended to mimic the best-effort decoding
// Chrome itself seems to apply when a Javascript URI is enetered into the omnibox (or clicked).
// See https://code.google.com/p/chromium/issues/detail?id=483000, #1611 and #1636.
decodeURIByParts(uri) {
return uri.split(/(?=%)/).map(function(uriComponent) {
try {
return decodeURIComponent(uriComponent);
} catch (error) {
return uriComponent;
}
}).join("");
},
// Completes a partial URL (without scheme)
createFullUrl(partialUrl) {
if (this.hasFullUrlPrefix(partialUrl))
return partialUrl;
else
return ("http://" + partialUrl);
},
// Tries to detect if :str is a valid URL.
isUrl(str) {
// Must not contain spaces
if (str.includes(' ')) { return false; }
// Starts with a scheme: URL
if (this.hasFullUrlPrefix(str)) { return true; }
// More or less RFC compliant URL host part parsing. This should be sufficient for our needs
const urlRegex = new RegExp(
'^(?:([^:]+)(?::([^:]+))?@)?' + // user:password (optional) => \1, \2
'([^:]+|\\[[^\\]]+\\])' + // host name (IPv6 addresses in square brackets allowed) => \3
'(?::(\\d+))?$' // port number (optional) => \4
);
// Official ASCII TLDs that are longer than 3 characters + inofficial .onion TLD used by TOR
const longTlds = ['arpa', 'asia', 'coop', 'info', 'jobs', 'local', 'mobi', 'museum', 'name', 'onion'];
const specialHostNames = ['localhost'];
// Try to parse the URL into its meaningful parts. If matching fails we're pretty sure that we don't have
// some kind of URL here.
const match = urlRegex.exec((str.split('/'))[0]);
if (!match) { return false; }
const hostName = match[3];
// Allow known special host names
if (specialHostNames.includes(hostName)) { return true; }
// Allow IPv6 addresses (need to be wrapped in brackets as required by RFC). It is sufficient to check for
// a colon, as the regex wouldn't match colons in the host name unless it's an v6 address
if (hostName.includes(':')) { return true; }
// At this point we have to make a decision. As a heuristic, we check if the input has dots in it. If yes,
// and if the last part could be a TLD, treat it as an URL
const dottedParts = hostName.split('.');
if (dottedParts.length > 1) {
const lastPart = dottedParts.pop();
if ((2 <= lastPart.length && lastPart.length <= 3) || longTlds.includes(lastPart)) { return true; }
}
// Allow IPv4 addresses
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostName)) { return true; }
// Fallback: no URL
return false;
},
// Map a search query to its URL encoded form. The query may be either a string or an array of strings.
// E.g. "BBC Sport" -> "BBC+Sport".
createSearchQuery(query) {
if (typeof(query) === "string") { query = query.split(/\s+/); }
return query.map(encodeURIComponent).join("+");
},
// Create a search URL from the given :query (using either the provided search URL, or the default one).
// It would be better to pull the default search engine from chrome itself. However, chrome does not provide
// an API for doing so.
createSearchUrl(query, searchUrl) {
if (searchUrl == null) { searchUrl = Settings.get("searchUrl"); }
if (!['%s', '%S'].some(token => searchUrl.indexOf(token) >= 0)) { searchUrl += "%s"; }
searchUrl = searchUrl.replace(/%S/g, query);
return searchUrl.replace(/%s/g, this.createSearchQuery(query));
},
// Extract a query from url if it appears to be a URL created from the given search URL.
// For example, map "https://www.google.ie/search?q=star+wars&foo&bar" to "star wars".
extractQuery: (() => {
const queryTerminator = new RegExp("[?&#/]");
const httpProtocolRegexp = new RegExp("^https?://");
return function(searchUrl, url) {
let suffixTerms;
url = url.replace(httpProtocolRegexp);
searchUrl = searchUrl.replace(httpProtocolRegexp);
[ searchUrl, ...suffixTerms ] = searchUrl.split("%s");
// We require the URL to start with the search URL.
if (!url.startsWith(searchUrl)) { return null; }
// We require any remaining terms in the search URL to also be present in the URL.
for (let suffix of suffixTerms) {
if (!(0 <= url.indexOf(suffix))) { return null; }
}
// We use try/catch because decodeURIComponent can throw an exception.
try {
return url.slice(searchUrl.length).split(queryTerminator)[0].split("+").map(decodeURIComponent).join(" ");
} catch (error) {
return null;
}
};
})(),
// Converts :string into a Google search if it's not already a URL. We don't bother with escaping characters
// as Chrome will do that for us.
convertToUrl(string) {
string = string.trim();
// Special-case about:[url], view-source:[url] and the like
if (Utils.hasChromePrefix(string)) {
return string;
} else if (Utils.hasJavascriptPrefix(string)) {
// In Chrome versions older than 46.0.2467.2, encoded javascript URIs weren't handled correctly.
if (Utils.haveChromeVersion("46.0.2467.2"))
return string;
else
return Utils.decodeURIByParts(string);
} else if (Utils.isUrl(string)) {
return Utils.createFullUrl(string);
} else {
return Utils.createSearchUrl(string);
}
},
// detects both literals and dynamically created strings
isString(obj) { return (typeof obj === 'string') || obj instanceof String; },
// Transform "zjkjkabz" into "abjkz".
distinctCharacters(str) {
const chars = str.split("");
return Array.from(new Set(chars)).sort().join("");
},
// Compares two version strings (e.g. "1.1" and "1.5") and returns
// -1 if versionA is < versionB, 0 if they're equal, and 1 if versionA is > versionB.
compareVersions(versionA, versionB) {
versionA = versionA.split(".");
versionB = versionB.split(".");
for (let i = 0, end = Math.max(versionA.length, versionB.length); i < end; i++) {
const a = parseInt(versionA[i] || 0, 10);
const b = parseInt(versionB[i] || 0, 10);
if (a < b) {
return -1;
} else if (a > b) {
return 1;
}
}
return 0;
},
// True if the current Chrome version is at least the required version.
haveChromeVersion(required) {
const match = navigator.appVersion.match(/Chrom(e|ium)\/(.*?) /);
const chromeVersion = match ? match[2] : null;
return chromeVersion && (0 <= Utils.compareVersions(chromeVersion, required));
},
// Zip two (or more) arrays:
// - Utils.zip([ [a,b], [1,2] ]) returns [ [a,1], [b,2] ]
// - Length of result is `arrays[0].length`.
// - Adapted from: http://stackoverflow.com/questions/4856717/javascript-equivalent-of-pythons-zip-function
zip(arrays) {
return arrays[0].map((_, i) => arrays.map(array => array[i]));
},
// locale-sensitive uppercase detection
hasUpperCase(s) { return s.toLowerCase() !== s; },
// Does string match any of these regexps?
matchesAnyRegexp(regexps, string) {
for (let re of regexps) {
if (re.test(string)) { return true; }
}
return false;
},
// Convenience wrapper for setTimeout (with the arguments around the other way).
setTimeout(ms, func) { return setTimeout(func, ms); },
// Like Nodejs's nextTick.
nextTick(func) { return this.setTimeout(0, func); },
// Make an idempotent function.
makeIdempotent(func) {
return function(...args) {
let previousFunc, ref;
const result = ([previousFunc, func] = Array.from(ref = [func, null]), ref)[0];
if (result) {
return result(...Array.from(args || []));
}
};
},
monitorChromeStorage(key, setter) {
return chrome.storage.local.get(key, obj => {
if (obj[key] != null) { setter(obj[key]); }
return chrome.storage.onChanged.addListener((changes, area) => {
if (changes[key] && (changes[key].newValue !== undefined)) {
return setter(changes[key].newValue);
}
});
});
}
};
// This creates a new function out of an existing function, where the new function takes fewer arguments. This
// allows us to pass around functions instead of functions + a partial list of arguments.
Function.prototype.curry = function() {
const fixedArguments = Array.copy(arguments);
const fn = this;
return function() { return fn.apply(this, fixedArguments.concat(Array.copy(arguments))); };
};
Array.copy = array => Array.prototype.slice.call(array, 0);
String.prototype.reverse = function() { return this.split("").reverse().join(""); };
// A simple cache. Entries used within two expiry periods are retained, otherwise they are discarded.
// At most 2 * @entries entries are retained.
class SimpleCache {
// expiry: expiry time in milliseconds (default, one hour)
// entries: maximum number of entries in @cache (there may be up to this many entries in @previous, too)
constructor(expiry, entries) {
if (expiry == null) { expiry = 60 * 60 * 1000; }
this.expiry = expiry;
if (entries == null) { entries = 1000; }
this.entries = entries;
this.cache = {};
this.previous = {};
this.lastRotation = new Date();
}
has(key) {
this.rotate();
return (key in this.cache) || key in this.previous;
}
// Set value, and return that value. If value is null, then delete key.
set(key, value = null) {
this.rotate();
delete this.previous[key];
if (value != null) {
return this.cache[key] = value;
} else {
delete this.cache[key];
return null;
}
}
get(key) {
this.rotate();
if (key in this.cache) {
return this.cache[key];
} else if (key in this.previous) {
this.cache[key] = this.previous[key];
delete this.previous[key];
return this.cache[key];
} else {
return null;
}
}
rotate(force) {
if (force == null) { force = false; }
Utils.nextTick(() => {
if (force || (this.entries < Object.keys(this.cache).length) || (this.expiry < (new Date() - this.lastRotation))) {
this.lastRotation = new Date();
this.previous = this.cache;
return this.cache = {};
}
});
}
clear() {
this.rotate(true);
return this.rotate(true);
}
}
// This is a simple class for the common case where we want to use some data value which may be immediately
// available, or for which we may have to wait. It implements a use-immediately-or-wait queue, and calls the
// fetch function to fetch the data asynchronously.
class AsyncDataFetcher {
constructor(fetch) {
this.data = null;
this.queue = [];
Utils.nextTick(() => {
return fetch(data => {
this.data = data;
for (let callback of this.queue) { callback(this.data); }
return this.queue = null;
});
});
}
use(callback) {
if (this.data != null) { return callback(this.data); } else { return this.queue.push(callback); }
}
}
// This takes a list of jobs (functions) and runs them, asynchronously. Functions queued with @onReady() are
// run once all of the jobs have completed.
class JobRunner {
constructor(jobs) {
this.jobs = jobs;
this.fetcher = new AsyncDataFetcher(callback => {
return this.jobs.map((job) =>
(job => {
Utils.nextTick(() => {
return job(() => {
this.jobs = this.jobs.filter(j => j !== job);
if (this.jobs.length === 0) { return callback(true); }
});
});
return null;
})(job));
});
}
onReady(callback) {
return this.fetcher.use(callback);
}
}
Object.assign(global, {Utils, SimpleCache, AsyncDataFetcher, JobRunner});

View file

@ -0,0 +1,47 @@
{
"background": {
"scripts": [ "lib/utils.js", "lib/settings.js", "background_scripts/bg_utils.js", "background_scripts/commands.js", "background_scripts/exclusions.js", "background_scripts/completion_engines.js", "background_scripts/completion_search.js", "background_scripts/completion.js", "background_scripts/marks.js", "background_scripts/main.js" ]
},
"browser_action": {
"default_icon": "icons/browser_action_disabled.png",
"default_popup": "pages/popup.html"
},
"content_scripts": [ {
"all_frames": true,
"css": [ "content_scripts/vimium.css" ],
"js": [ "lib/utils.js", "lib/keyboard_utils.js", "lib/dom_utils.js", "lib/rect.js", "lib/handler_stack.js", "lib/settings.js", "lib/find_mode_history.js", "content_scripts/mode.js", "content_scripts/ui_component.js", "content_scripts/link_hints.js", "content_scripts/vomnibar.js", "content_scripts/scroller.js", "content_scripts/marks.js", "content_scripts/mode_insert.js", "content_scripts/mode_find.js", "content_scripts/mode_key_handler.js", "content_scripts/mode_visual.js", "content_scripts/hud.js", "content_scripts/mode_normal.js", "content_scripts/vimium_frontend.js" ],
"match_about_blank": true,
"matches": [ "\u003Call_urls>" ],
"run_at": "document_start"
}, {
"all_frames": true,
"js": [ "content_scripts/injected.js" ],
"match_about_blank": true,
"matches": [ "\u003Call_urls>" ],
"run_at": "document_start"
}, {
"all_frames": true,
"css": [ "content_scripts/file_urls.css" ],
"matches": [ "file:///", "file:///*/" ],
"run_at": "document_start"
} ],
"description": "The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.",
"icons": {
"128": "icons/icon128.png",
"16": "icons/icon16.png",
"48": "icons/icon48.png"
},
"key": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCavizCZ9EnBGbtcRmMErcaxD2WUHJ9ME8IYGQhUBlFgIvchJjAO8koyak3AM95dqu3sOLdtIYD+75T82V1Wl5fLnHAeij2/IWL2VViTHeZhXZl1+rD9sRDaEYd7aZetpJ29+XXfhVphKArCCfwbYCtoJhTIr6S6DYsXuRevoV0EwIDAQAB",
"manifest_version": 2,
"minimum_chrome_version": "69.0",
"name": "Vimium",
"options_ui": {
"chrome_style": false,
"open_in_tab": true,
"page": "pages/options.html"
},
"permissions": [ "tabs", "bookmarks", "history", "clipboardRead", "storage", "sessions", "notifications", "webNavigation", "\u003Call_urls>" ],
"update_url": "https://clients2.google.com/service/update2/crx",
"version": "1.67",
"web_accessible_resources": [ "pages/vomnibar.html", "content_scripts/vimium.css", "pages/hud.html", "pages/help_dialog.html", "pages/completion_engines.html" ]
}

View file

@ -0,0 +1,24 @@
{
"name": "vimium",
"version": "1.0.0",
"description": "Vimium - The Hacker's Browser",
"main": "make.js",
"directories": {
"lib": "lib",
"test": "tests"
},
"dependencies": {
"path": "^0.11.14",
"puppeteer": "^3.1.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/philc/vimium.git"
},
"author": "Phil Crosby",
"license": "ISC",
"bugs": {
"url": "https://github.com/philc/vimium/issues"
},
"homepage": "https://github.com/philc/vimium#readme"
}

View file

@ -0,0 +1,29 @@
<html>
<head>
<title>New Tab</title>
<script src="../lib/utils.js"></script>
<script src="../lib/keyboard_utils.js"></script>
<script src="../lib/dom_utils.js"></script>
<script src="../lib/rect.js"></script>
<script src="../lib/handler_stack.js"></script>
<script src="../lib/settings.js"></script>
<script src="../lib/find_mode_history.js"></script>
<script src="../content_scripts/mode.js"></script>
<script src="../content_scripts/ui_component.js"></script>
<script src="../content_scripts/link_hints.js"></script>
<script src="../content_scripts/vomnibar.js"></script>
<script src="../content_scripts/scroller.js"></script>
<script src="../content_scripts/marks.js"></script>
<script src="../content_scripts/mode_insert.js"></script>
<script src="../content_scripts/mode_find.js"></script>
<script src="../content_scripts/mode_key_handler.js"></script>
<script src="../content_scripts/mode_visual.js"></script>
<script src="../content_scripts/hud.js"></script>
<script src="../content_scripts/mode_normal.js"></script>
<script src="../content_scripts/vimium_frontend.js"></script>
<link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" />
</head>
<body class="vimiumBody">
</body>
</html>

View file

@ -0,0 +1,15 @@
div#wrapper
{
width: 730px;
}
h4, h5
{
color: #777;
}
div.engine
{
margin-left: 20px;
}

View file

@ -0,0 +1,55 @@
<html>
<head>
<title>Vimium Search Completion</title>
<!-- We re-use some styling from the options page, so that the look and feel here is similar -->
<link rel="stylesheet" type="text/css" href="options.css">
<link rel="stylesheet" type="text/css" href="completion_engines.css">
<script src="../lib/utils.js"></script>
<script src="../lib/keyboard_utils.js"></script>
<script src="../lib/dom_utils.js"></script>
<script src="../lib/rect.js"></script>
<script src="../lib/handler_stack.js"></script>
<script src="../lib/settings.js"></script>
<script src="../lib/find_mode_history.js"></script>
<script src="../content_scripts/mode.js"></script>
<script src="../content_scripts/ui_component.js"></script>
<script src="../content_scripts/link_hints.js"></script>
<script src="../content_scripts/vomnibar.js"></script>
<script src="../content_scripts/scroller.js"></script>
<script src="../content_scripts/marks.js"></script>
<script src="../content_scripts/mode_insert.js"></script>
<script src="../content_scripts/mode_find.js"></script>
<script src="../content_scripts/mode_key_handler.js"></script>
<script src="../content_scripts/mode_visual.js"></script>
<script src="../content_scripts/hud.js"></script>
<script src="../content_scripts/mode_normal.js"></script>
<script src="../content_scripts/vimium_frontend.js"></script>
<link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" />
<script src="../background_scripts/completion_engines.js"></script>
<script src="completion_engines.js"></script>
</head>
<body>
<div id="wrapper">
<header>Vimium Search Completion</header>
<p>
Search completion is available for custom search engines whose search URL matches one of Vimium's
built-in completion engines; that is, the search URL matches one of the regular expressions below.
Search completion is not available for the default search engine.
</p>
<p>
Custom search engines can be configured on the <a href="options.html" target="_blank">options</a>
page. <br/>
Further information is available on the <a href="https://github.com/philc/vimium/wiki/Search-Completion" target="_blank">wiki</a>.
</p>
<header>Available Completion Engines</header>
<p>
Search completion is available in this version of Vimium for the the following custom search engines.
</p>
<p>
<dl id="engineList"></dl>
</p>
</div>
</body>
</html>

View file

@ -0,0 +1,38 @@
const cleanUpRegexp = (re) => re.toString()
.replace(/^\//, '')
.replace(/\/$/, '')
.replace(/\\\//g, "/");
DomUtils.documentReady(function() {
const html = [];
for (let engine of CompletionEngines.slice(0, CompletionEngines.length-1)) {
engine = new engine;
html.push(`<h4>${engine.constructor.name}</h4>\n`);
html.push("<div class=\"engine\">");
if (engine.example.explanation)
html.push(`<p>${engine.example.explanation}</p>`);
if (engine.example.searchUrl && engine.example.keyword) {
if (!engine.example.description)
engine.example.description = engine.constructor.name;
html.push("<p>");
html.push("Example:");
html.push("<pre>");
html.push(`${engine.example.keyword}: ${engine.example.searchUrl} ${engine.example.description}`);
html.push("</pre>");
html.push("</p>");
}
if (engine.regexps) {
html.push("<p>");
html.push(`Regular expression${1 < engine.regexps.length ? 's' : ''}:`);
html.push("<pre>");
for (let re of engine.regexps)
html.push(`${cleanUpRegexp(re)}\n`);
html.push("</pre>");
html.push("</p>");
}
html.push("</div>");
}
document.getElementById("engineList").innerHTML = html.join("");
});

View file

@ -0,0 +1,13 @@
<table id="exclusionRules">
<tr>
<td><span class="exclusionHeaderText">Patterns</span></td>
<td><span class="exclusionHeaderText">Keys</span></td>
</tr>
</table>
<template id="exclusionRuleTemplate">
<tr class="exclusionRuleTemplateInstance">
<td><input/ type="text" class="pattern" spellcheck="false" placeholder="URL pattern"></td>
<td class="exclusionRulePassKeys"><input/ type="text" class="passKeys" spellcheck="false" placeholder="Exclude keys"></td>
<td class="exclusionRemove"><input/ type="button" class="exclusionRemoveButton" value="&#x2716;"></td>
</tr>
</template>

View file

@ -0,0 +1,142 @@
<html>
<head>
<title>Vimium Help</title>
<script src="../lib/utils.js"></script>
<script src="../lib/keyboard_utils.js"></script>
<script src="../lib/dom_utils.js"></script>
<script src="../lib/rect.js"></script>
<script src="../lib/handler_stack.js"></script>
<script src="../lib/settings.js"></script>
<script src="../lib/find_mode_history.js"></script>
<script src="../content_scripts/mode.js"></script>
<script src="../content_scripts/ui_component.js"></script>
<script src="../content_scripts/link_hints.js"></script>
<script src="../content_scripts/vomnibar.js"></script>
<script src="../content_scripts/scroller.js"></script>
<script src="../content_scripts/marks.js"></script>
<script src="../content_scripts/mode_insert.js"></script>
<script src="../content_scripts/mode_find.js"></script>
<script src="../content_scripts/mode_key_handler.js"></script>
<script src="../content_scripts/mode_visual.js"></script>
<script src="../content_scripts/hud.js"></script>
<script src="../content_scripts/mode_normal.js"></script>
<script src="../content_scripts/vimium_frontend.js"></script>
<link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" />
<script type="text/javascript" src="ui_component_server.js"></script>
<script type="text/javascript" src="help_dialog.js"></script>
</head>
<body>
<!--
This is shown when typing "?". This HTML is loaded by the background page and then populated with the
latest keybindings information before displaying.
-->
<!-- Note that the template placeholders (e.g. "pageNavigation") will be filled in by the background
page with the up-to-date key bindings when the dialog is shown. -->
<div id="vimiumHelpDialogContainer">
<div id="vimiumHelpDialog">
<div>
<table>
<tr>
<td>
<span id="vimiumTitle"><span style="color:#2f508e">Vim</span>ium <span id="help-dialog-title"></span></span>
</td>
<td class="vimiumHelpDialogTopButtons">
<a class="vimiumHelDialogLink" id="helpDialogOptionsPage" href="#">Options</a>
<a class="vimiumHelDialogLink" id="helpDialogWikiPage" href="https://github.com/philc/vimium/wiki" target="_blank">Wiki</a>
<a class="closeButton" href="#">&times;</a>
</td>
</tr>
</table>
</div>
<div class="vimiumDivider"></div>
<div class="vimiumColumn">
<table>
<thead>
<tr><td></td><td></td><td class="vimiumHelpSectionTitle">Navigating the page</td></tr>
</thead>
<tbody id="help-dialog-pageNavigation">
</tbody>
</table>
</div>
<div class="vimiumColumn">
<table >
<thead>
<tr><td></td><td></td><td class="vimiumHelpSectionTitle">Using the vomnibar</td></tr>
</thead>
<tbody id="help-dialog-vomnibarCommands"></tbody>
<thead>
<tr><td></td><td></td><td class="vimiumHelpSectionTitle">Using find</td></tr>
</thead>
<tbody id="help-dialog-findCommands"></tbody>
<thead>
<tr><td></td><td></td><td class="vimiumHelpSectionTitle">Navigating history</td></tr>
</thead>
<tbody id="help-dialog-historyNavigation"></tbody>
<thead>
<tr><td></td><td></td><td class="vimiumHelpSectionTitle">Manipulating tabs</td></tr>
</thead>
<tbody id="help-dialog-tabManipulation"></tbody>
<thead>
<tr><td></td><td></td><td class="vimiumHelpSectionTitle">Miscellaneous</td></tr>
</thead>
<tbody id="help-dialog-misc"></tbody>
</table>
</div>
<div>
<table class="helpDialogBottom">
<tr>
<td class="helpDialogBottomLeft">
<span id="help-dialog-tip"></span>
</td>
<td class="helpDialogBottomRight vimiumHelDialogLink">
<a href="#" id="toggleAdvancedCommands">Show advanced commands</a>
</td>
</tr>
</table>
</div>
<br clear="both"/>
<div class="vimiumDivider"></div>
<div id="vimiumHelpDialogFooter">
<div class="vimiumColumn">
Enjoying Vimium?
<a class="vimiumHelDialogLink" target="_blank"
href="https://chrome.google.com/webstore/detail/vimium/dbepggeogbaibhgnhhndojpepiihcmeb/reviews">Leave us
feedback</a>.<br/>
Found a bug? <a class="vimiumHelDialogLink" target="_blank" href="https://github.com/philc/vimium/issues">Report it here</a>.
</div>
<div class="vimiumColumn" style="text-align:right">
<span>Version <span id="help-dialog-version"></span></span><br/>
<a href="https://github.com/philc/vimium/blob/master/CHANGELOG.md" target="_blank" class="vimiumHelDialogLink">What's new?</a>
</div>
</div>
</div>
</div>
<template id="helpDialogEntry">
<tr>
<td class="vimiumKeyBindings"></td>
<td></td>
<td class="vimiumHelpDescription"></td>
</tr>
</template>
<template id="helpDialogEntryBindingsOnly">
<tr>
<td class="vimiumKeyBindings" colspan="3" style="text-align: left"></td>
</tr>
</template>
<template id="keysTemplate"><span><span class="vimiumHelpDialogKey"></span><span class="commaSeparator">, </span></span></template>
<template id="commandNameTemplate">
<span class="vimiumCopyCommandName">(<span class="vimiumCopyCommandNameName" role="link"></span>)</span>
</template>
</body>
</html>

View file

@ -0,0 +1,196 @@
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS203: Remove `|| {}` from converted for-own loops
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const $ = id => document.getElementById(id);
const $$ = (element, selector) => element.querySelector(selector);
// The ordering we show key bindings is alphanumerical, except that special keys sort to the end.
const compareKeys = function(a,b) {
a = a.replace("<","~");
b = b.replace("<", "~");
if (a < b)
return -1;
else if (b < a)
return 1;
else
return 0;
};
// This overrides the HelpDialog implementation in vimium_frontend.js. We provide aliases for the two
// HelpDialog methods required by normalMode (isShowing() and toggle()).
var HelpDialog = {
dialogElement: null,
isShowing() { return true; },
// This setting is pulled out of local storage. It's false by default.
getShowAdvancedCommands() { return Settings.get("helpDialog_showAdvancedCommands"); },
init() {
if (this.dialogElement != null)
return;
this.dialogElement = document.getElementById("vimiumHelpDialog");
this.dialogElement.getElementsByClassName("closeButton")[0].addEventListener("click", clickEvent => {
clickEvent.preventDefault();
this.hide();
}, false);
document.getElementById("helpDialogOptionsPage").addEventListener("click", function(clickEvent) {
clickEvent.preventDefault();
chrome.runtime.sendMessage({handler: "openOptionsPageInNewTab"});
}, false);
document.getElementById("toggleAdvancedCommands").
addEventListener("click", HelpDialog.toggleAdvancedCommands.bind(HelpDialog), false);
document.documentElement.addEventListener("click", event => {
if (!this.dialogElement.contains(event.target))
this.hide();
} , false);
},
instantiateHtmlTemplate(parentNode, templateId, callback) {
const templateContent = document.querySelector(templateId).content;
const node = document.importNode(templateContent, true);
parentNode.appendChild(node);
callback(parentNode.lastElementChild);
},
show({showAllCommandDetails}) {
$("help-dialog-title").textContent = showAllCommandDetails ? "Command Listing" : "Help";
$("help-dialog-version").textContent = Utils.getCurrentVersion();
chrome.storage.local.get("helpPageData", ({helpPageData}) => {
for (let group of Object.keys(helpPageData)) {
const commands = helpPageData[group];
const container = this.dialogElement.querySelector(`#help-dialog-${group}`);
container.innerHTML = "";
for (var command of Array.from(commands)) {
if (!showAllCommandDetails && command.keys.length == 0)
continue;
let keysElement = null;
let descriptionElement = null;
const useTwoRows = command.keys.join(", ").length >= 12;
if (!useTwoRows) {
this.instantiateHtmlTemplate(container, "#helpDialogEntry", function(element) {
if (command.advanced)
element.classList.add("advanced");
keysElement = (descriptionElement = element);
});
} else {
this.instantiateHtmlTemplate(container, "#helpDialogEntryBindingsOnly", function(element) {
if (command.advanced)
element.classList.add("advanced");
keysElement = element;
});
this.instantiateHtmlTemplate(container, "#helpDialogEntry", function(element) {
if (command.advanced)
element.classList.add("advanced");
descriptionElement = element;
});
}
$$(descriptionElement, ".vimiumHelpDescription").textContent = command.description;
keysElement = $$(keysElement, ".vimiumKeyBindings");
let lastElement = null;
for (var key of command.keys.sort(compareKeys)) {
this.instantiateHtmlTemplate(keysElement, "#keysTemplate", function(element) {
lastElement = element;
$$(element, ".vimiumHelpDialogKey").textContent = key;
});
}
// And strip off the trailing ", ", if necessary.
if (lastElement)
lastElement.removeChild($$(lastElement, ".commaSeparator"));
if (showAllCommandDetails) {
this.instantiateHtmlTemplate($$(descriptionElement, ".vimiumHelpDescription"), "#commandNameTemplate", function(element) {
const commandNameElement = $$(element, ".vimiumCopyCommandNameName");
commandNameElement.textContent = command.command;
commandNameElement.title = `Click to copy \"${command.command}\" to clipboard.`;
commandNameElement.addEventListener("click", function() {
HUD.copyToClipboard(commandNameElement.textContent);
HUD.showForDuration(`Yanked ${commandNameElement.textContent}.`, 2000);
});
});
}
// }
}
}
this.showAdvancedCommands(this.getShowAdvancedCommands());
// "Click" the dialog element (so that it becomes scrollable).
DomUtils.simulateClick(this.dialogElement);
});
},
hide() {
UIComponentServer.hide();
},
toggle() {
this.hide();
},
//
// Advanced commands are hidden by default so they don't overwhelm new and casual users.
//
toggleAdvancedCommands(event) {
const vimiumHelpDialogContainer = $("vimiumHelpDialogContainer");
const scrollHeightBefore = vimiumHelpDialogContainer.scrollHeight;
event.preventDefault();
const showAdvanced = HelpDialog.getShowAdvancedCommands();
HelpDialog.showAdvancedCommands(!showAdvanced);
Settings.set("helpDialog_showAdvancedCommands", !showAdvanced);
// Try to keep the "show advanced commands" button in the same scroll position.
const scrollHeightDelta = vimiumHelpDialogContainer.scrollHeight - scrollHeightBefore;
if (scrollHeightDelta > 0)
vimiumHelpDialogContainer.scrollTop += scrollHeightDelta;
},
showAdvancedCommands(visible) {
document.getElementById("toggleAdvancedCommands").textContent =
visible ? "Hide advanced commands" : "Show advanced commands";
// Add/remove the showAdvanced class to show/hide advanced commands.
const addOrRemove = visible ? "add" : "remove";
HelpDialog.dialogElement.classList[addOrRemove]("showAdvanced");
}
};
UIComponentServer.registerHandler(function(event) {
switch (event.data.name != null ? event.data.name : event.data) {
case "hide": HelpDialog.hide(); break;
case "activate":
HelpDialog.init();
HelpDialog.show(event.data);
Frame.postMessage("registerFrame");
// If we abandoned (see below) in a mode with a HUD indicator, then we have to reinstate it.
Mode.setIndicator();
break;
case "hidden":
// Unregister the frame, so that it's not available for `gf` or link hints.
Frame.postMessage("unregisterFrame");
// Abandon any HUD which might be showing within the help dialog.
HUD.abandon();
break;
}
});
document.addEventListener("DOMContentLoaded", function() {
DomUtils.injectUserCss(); // Manually inject custom user styles.
});
global.HelpDialog = HelpDialog;
global.isVimiumHelpDialog = true;

View file

@ -0,0 +1,21 @@
<html>
<head>
<title>HUD</title>
<link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" />
<script type="text/javascript" src="../lib/utils.js"></script>
<script type="text/javascript" src="../lib/dom_utils.js"></script>
<script type="text/javascript" src="../lib/settings.js"></script>
<script type="text/javascript" src="../lib/keyboard_utils.js"></script>
<script type="text/javascript" src="../lib/find_mode_history.js"></script>
<script type="text/javascript" src="../lib/clipboard.js"></script>
<script type="text/javascript" src="ui_component_server.js"></script>
<script type="text/javascript" src="hud.js"></script>
</head>
<body>
<div class="vimiumHUD">
<div class="vimiumHUDSearchArea">
<div class="vimiumHUDSearchAreaInner" id="hud"></div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,177 @@
let findMode = null;
// Chrome creates a unique port for each MessageChannel, so there's a race condition between JavaScript
// messages of Vimium and browser messages during style recomputation. This duration was determined
// empirically. See https://github.com/philc/vimium/pull/3277#discussion_r283080348
const TIME_TO_WAIT_FOR_IPC_MESSAGES = 17;
// Set the input element's text, and move the cursor to the end.
const setTextInInputElement = function(inputElement, text) {
inputElement.textContent = text;
// Move the cursor to the end. Based on one of the solutions here:
// http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity
const range = document.createRange();
range.selectNodeContents(inputElement);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
};
// Manually inject custom user styles.
document.addEventListener("DOMContentLoaded", () => DomUtils.injectUserCss());
const onKeyEvent = function(event) {
// Handle <Enter> on "keypress", and other events on "keydown"; this avoids interence with CJK translation
// (see #2915 and #2934).
let rawQuery;
if ((event.type === "keypress") && (event.key !== "Enter"))
return null;
if ((event.type === "keydown") && (event.key === "Enter"))
return null;
const inputElement = document.getElementById("hud-find-input");
if (inputElement == null) // Don't do anything if we're not in find mode.
return;
if ((KeyboardUtils.isBackspace(event) && (inputElement.textContent.length === 0)) ||
(event.key === "Enter") || KeyboardUtils.isEscape(event)) {
inputElement.blur();
UIComponentServer.postMessage({
name: "hideFindMode",
exitEventIsEnter: event.key === "Enter",
exitEventIsEscape: KeyboardUtils.isEscape(event)
});
} else if (event.key === "ArrowUp") {
if (rawQuery = FindModeHistory.getQuery(findMode.historyIndex + 1)) {
findMode.historyIndex += 1;
if (findMode.historyIndex === 0) {
findMode.partialQuery = findMode.rawQuery;
}
setTextInInputElement(inputElement, rawQuery);
findMode.executeQuery();
}
} else if (event.key === "ArrowDown") {
findMode.historyIndex = Math.max(-1, findMode.historyIndex - 1);
rawQuery = 0 <= findMode.historyIndex ? FindModeHistory.getQuery(findMode.historyIndex) : findMode.partialQuery;
setTextInInputElement(inputElement, rawQuery);
findMode.executeQuery();
} else {
return;
}
DomUtils.suppressEvent(event);
return false;
};
document.addEventListener("keydown", onKeyEvent);
document.addEventListener("keypress", onKeyEvent);
const handlers = {
show(data) {
document.getElementById("hud").innerText = data.text;
document.getElementById("hud").classList.add("vimiumUIComponentVisible");
document.getElementById("hud").classList.remove("vimiumUIComponentHidden");
document.getElementById("hud").classList.remove("hud-find");
},
hidden() {
// We get a flicker when the HUD later becomes visible again (with new text) unless we reset its contents
// here.
document.getElementById("hud").innerText = "";
document.getElementById("hud").classList.add("vimiumUIComponentHidden");
document.getElementById("hud").classList.remove("vimiumUIComponentVisible");
},
showFindMode(data) {
let executeQuery;
const hud = document.getElementById("hud");
hud.classList.add("hud-find");
const inputElement = document.createElement("span");
try { // NOTE(mrmr1993): Chrome supports non-standard "plaintext-only", which is what we *really* want.
inputElement.contentEditable = "plaintext-only";
} catch (error) { // Fallback to standard-compliant version.
inputElement.contentEditable = "true";
}
inputElement.id = "hud-find-input";
hud.appendChild(inputElement);
inputElement.addEventListener("input", (executeQuery = function(event) {
// On Chrome when IME is on, the order of events is:
// keydown, input.isComposing=true, keydown, input.true, ..., keydown, input.true, compositionend;
// while on Firefox, the order is: keydown, input.true, ..., input.true, keydown, compositionend, input.false.
// Therefore, check event.isComposing here, to avoid window focus changes during typing with IME,
// since such changes will prevent normal typing on Firefox (see #3480)
if (Utils.isFirefox() && event.isComposing)
return;
// Replace \u00A0 (&nbsp;) with a normal space.
findMode.rawQuery = inputElement.textContent.replace("\u00A0", " ");
UIComponentServer.postMessage({name: "search", query: findMode.rawQuery});
}));
const countElement = document.createElement("span");
countElement.id = "hud-match-count";
countElement.style.float = "right";
hud.appendChild(countElement);
Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, function() {
// On Firefox, the page must first be focused before the HUD input element can be focused. #3460.
if (Utils.isFirefox())
window.focus();
inputElement.focus();
});
findMode = {
historyIndex: -1,
partialQuery: "",
rawQuery: "",
executeQuery
};
},
updateMatchesCount({matchCount, showMatchText}) {
const countElement = document.getElementById("hud-match-count");
if (countElement == null) // Don't do anything if we're not in find mode.
return;
if (Utils.isFirefox())
document.getElementById("hud-find-input").focus()
const countText = matchCount > 0 ?
` (${matchCount} Match${matchCount === 1 ? "" : "es"})` : " (No matches)";
countElement.textContent = showMatchText ? countText : "";
},
copyToClipboard(data) {
Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, function() {
const focusedElement = document.activeElement;
Clipboard.copy(data);
if (focusedElement != null)
focusedElement.focus();
window.parent.focus();
UIComponentServer.postMessage({name: "unfocusIfFocused"});
});
},
pasteFromClipboard() {
Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, function() {
const focusedElement = document.activeElement;
const data = Clipboard.paste();
if (focusedElement != null)
focusedElement.focus();
window.parent.focus();
UIComponentServer.postMessage({name: "pasteResponse", data});
});
},
settings({ isFirefox }) {
return Utils.isFirefox = () => isFirefox;
}
};
UIComponentServer.registerHandler(function({data}) {
const handler = handlers[data.name || data];
if (handler)
return handler(data);
});
FindModeHistory.init();

View file

@ -0,0 +1,68 @@
<html>
<head>
<title>Vimium Logging</title>
<script src="../lib/utils.js"></script>
<script src="../lib/keyboard_utils.js"></script>
<script src="../lib/dom_utils.js"></script>
<script src="../lib/rect.js"></script>
<script src="../lib/handler_stack.js"></script>
<script src="../lib/settings.js"></script>
<script src="../lib/find_mode_history.js"></script>
<script src="../content_scripts/mode.js"></script>
<script src="../content_scripts/ui_component.js"></script>
<script src="../content_scripts/link_hints.js"></script>
<script src="../content_scripts/vomnibar.js"></script>
<script src="../content_scripts/scroller.js"></script>
<script src="../content_scripts/marks.js"></script>
<script src="../content_scripts/mode_insert.js"></script>
<script src="../content_scripts/mode_find.js"></script>
<script src="../content_scripts/mode_key_handler.js"></script>
<script src="../content_scripts/mode_visual.js"></script>
<script src="../content_scripts/hud.js"></script>
<script src="../content_scripts/mode_normal.js"></script>
<script src="../content_scripts/vimium_frontend.js"></script>
<link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" />
<script src="logging.js"></script>
<style type="text/css">
body {
font: 14px "DejaVu Sans", "Arial", sans-serif;
color: #303942;
margin: 0 auto;
}
div#wrapper {
margin: 0px 35px;
width: calc(100% - 70px);
}
header {
font-size: 18px;
font-weight: normal;
border-bottom: 1px solid #eee;
padding: 20px 0 15px 0;
width: 100%;
}
#log-text {
width: 100%;
height: 80%;
}
#branchRef-wrapper {
display: none;
}
#branchRef-wrapper.no-hide {
display: block;
}
</style>
</head>
<body>
<div id="wrapper">
<header>Vimium Log</header>
<br />
<textarea id="log-text" readonly></textarea>
<br />
Version: <span id="vimiumVersion"></span><br />
Installed: <span id="installDate"></span>
<div id="branchRef-wrapper">Branch: <span id="branchRef"></span></div>
</div>
</body>
</html>

View file

@ -0,0 +1,20 @@
const $ = id => document.getElementById(id);
document.addEventListener("DOMContentLoaded", function() {
DomUtils.injectUserCss(); // Manually inject custom user styles.
$("vimiumVersion").innerText = Utils.getCurrentVersion();
chrome.storage.local.get("installDate", items => $("installDate").innerText = items.installDate.toString());
const branchRefRequest = new XMLHttpRequest();
branchRefRequest.addEventListener("load", function() {
const branchRefParts = branchRefRequest.responseText.split("refs/heads/", 2);
if (branchRefParts.length === 2)
$("branchRef").innerText = branchRefParts[1];
else
$("branchRef").innerText = `HEAD detatched at ${branchRefParts[0]}`;
$("branchRef-wrapper").classList.add("no-hide");
});
branchRefRequest.open("GET", chrome.extension.getURL(".git/HEAD"));
branchRefRequest.send();
});

View file

@ -0,0 +1,237 @@
/* NOTE: This stylesheet is included in both options.html and popup.html. So changes here affect
both of these. */
body {
font: 14px "DejaVu Sans", "Arial", sans-serif;
color: #303942;
margin: 0 auto;
}
a, a:visited { color: #15c; }
a:active { color: #052577; }
div#wrapper, #footerWrapper {
width: 540px;
margin-left: 35px;
}
header {
font-size: 18px;
font-weight: normal;
border-bottom: 1px solid #eee;
padding: 20px 0 15px 0;
width: 100%;
}
button {
-webkit-user-select: none;
-webkit-appearance: none;
background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);
border: 1px solid rgba(0, 0, 0, 0.25);
border-radius: 2px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);
color: #444;
font: inherit;
text-shadow: 0 1px 0 #f0f0f0;
height: 24px;
font-size: 12px;
padding: 0 10px;
}
button:hover {
background-image: -webkit-linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0);
border-color: rgba(0, 0, 0, 0.3);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12), inset 0 1px 2px rgba(255, 255, 255, 0.95);
color: black;
}
button:active {
background-image: -webkit-linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7);
box-shadow: none;
text-shadow: none;
}
button[disabled], button[disabled]:hover, button[disabled]:active {
background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);
border: 1px solid rgba(0, 0, 0, 0.25);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);
text-shadow: 0 1px 0 #f0f0f0;
color: #888;
}
input[type="checkbox"] {
-webkit-user-select: none;
}
label:hover {
color: black;
}
pre, code, .code {
font-family: Consolas, "Liberation Mono", Courier, monospace;
}
pre {
margin: 5px;
border-left: 1px solid #eee;
padding-left: 5px;
}
input, textarea {
box-sizing: border-box;
}
textarea {
/* Horizontal resizing is pretty screwy-looking. */
resize: vertical;
}
table#options{
width: 100%;
font-size: 14px;
position: relative;
border-spacing: 0 23px;
}
.example {
font-size: 12px;
line-height: 16px;
color: #979ca0;
margin-left: 20px;
}
.info {
margin-left: 0px;
}
.caption {
margin-right: 10px;
min-width: 130px;
padding-top: 3px;
vertical-align: top;
}
td { padding: 0; }
div#exampleKeyMapping {
margin-left: 10px;
margin-top: 5px;
}
input#linkHintCharacters {
width: 100%;
}
input#linkHintNumbers {
width: 100%;
}
input#linkHintCharacters {
width: 100%;
}
input#scrollStepSize {
width: 50px;
margin-right: 3px;
padding-left: 3px;
}
textarea#userDefinedLinkHintCss, textarea#keyMappings, textarea#searchEngines {
width: 100%;;
min-height: 140px;
white-space: pre;
}
input#previousPatterns, input#nextPatterns {
width: 100%;
}
input#newTabUrl {
width: 100%;
}
input#searchUrl {
width: 100%;
}
#status {
margin-left: 10px;
font-size: 80%;
}
/* Make the caption in the settings table as small as possible, to pull the other fields to the right. */
.caption {
width: 1px;
white-space: nowrap;
}
#buttonsPanel { width: 100%; }
#advancedOptions { display: none; }
#advancedOptionsButton { width: 170px; }
.help {
position: absolute;
right: -320px;
width: 320px;
}
input[type=text]:read-only, input[type=number]:read-only, textarea:read-only {
background-color: #eee;
color: #666;
pointer-events: none;
-webkit-user-select: none;
}
input[type="text"], textarea {
border: 1px solid #bfbfbf;
border-radius: 2px;
color: #444;
background-color: white;
font: inherit;
padding: 3px;
}
button:focus, input[type="text"]:focus, textarea:focus {
-webkit-transition: border-color 200ms;
border-color: #4d90fe;
outline: none;
}
/* Boolean options have a tighter form representation than text options. */
td.booleanOption { font-size: 12px; }
/* Ids and classes for rendering exclusionRules */
#exclusionScrollBox {
overflow: scroll;
overflow-x: hidden;
overflow-y: auto;
/* Each exclusion rule is about 30px, so this allows 7 before scrolling */
max-height: 215px;
min-height: 75px;
border-radius: 2px;
color: #444;
width: 100%
}
#exclusionRules {
width: 100%;
}
.exclusionRulePassKeys {
width: 33%;
}
.exclusionRemove {
width: 1px; /* 1px; smaller than the button itself. */
}
.exclusionRemoveButton {
border: none;
background-color: #fff;
color: #979ca0;
}
.exclusionRemoveButton:hover {
color: #444;
}
input.pattern, input.passKeys, .exclusionHeaderText {
width: 100%;
font-family: Consolas, "Liberation Mono", Courier, monospace;
font-size: 14px;
}
.exclusionHeaderText {
padding-left: 3px;
color: #979ca0;
}
#exclusionAddButton {
float: right;
margin-right: 0px;
margin-top: 5px;
}
#footer {
background: #f5f5f5;
border-top: 1px solid #979ca0;
position: fixed;
bottom: 0px;
z-index: 10;
}
#footer, #footerTable, #footerTableData {
width: 100%;
}
#endSpace {
/* Leave space for the fixed footer. */
min-height: 30px;
max-height: 30px;
}
#helpText, #versionAndOptions {
font-size: 12px;
}
#saveOptionsTableData {
float: right;
}
#saveOptions, #exclusionAddButton {
white-space: nowrap;
width: 110px;
}
#backupLink {
cursor: pointer;
}

View file

@ -0,0 +1,371 @@
<html>
<head>
<title>Vimium Options</title>
<link rel="stylesheet" type="text/css" href="options.css">
<script src="../lib/utils.js"></script>
<script src="../lib/keyboard_utils.js"></script>
<script src="../lib/dom_utils.js"></script>
<script src="../lib/rect.js"></script>
<script src="../lib/handler_stack.js"></script>
<script src="../lib/settings.js"></script>
<script src="../lib/find_mode_history.js"></script>
<script src="../content_scripts/mode.js"></script>
<script src="../content_scripts/ui_component.js"></script>
<script src="../content_scripts/link_hints.js"></script>
<script src="../content_scripts/vomnibar.js"></script>
<script src="../content_scripts/scroller.js"></script>
<script src="../content_scripts/marks.js"></script>
<script src="../content_scripts/mode_insert.js"></script>
<script src="../content_scripts/mode_find.js"></script>
<script src="../content_scripts/mode_key_handler.js"></script>
<script src="../content_scripts/mode_visual.js"></script>
<script src="../content_scripts/hud.js"></script>
<script src="../content_scripts/mode_normal.js"></script>
<script src="../content_scripts/vimium_frontend.js"></script>
<link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" />
<script type="text/javascript" src="options.js"></script>
</head>
<body class="vimiumBody">
<div id="wrapper">
<header>Vimium Options</header>
<table id="options">
<tr>
<td class="caption">Excluded URLs<br/>and keys</td>
<td>
<div class="help">
<div class="example">
Wholly or partially disable Vimium. "Patterns" are URL regular expressions;
additionally, "*" matches any zero or more characters.
<br/><br/>
If "Keys" is left empty, then Vimium is wholly disabled.
Otherwise, just the listed keys are disabled (they are passed through).
</div>
</div>
<div>
<div id="exclusionScrollBox">
<!-- Populated from exclusions.html by options.js. -->
</div>
<button id="exclusionAddButton">Add Rule</button>
</div>
</td>
</tr>
<tr>
<td class="caption">Custom key<br/>mappings</td>
<td id="mappingsHelp" verticalAlign="top">
<div class="help">
<div class="example">
<!-- TODO(ilya/philc): Expand this and style it better. -->
Enter commands to remap your keys. Available commands:<br/>
<pre id="exampleKeyMapping">
map j scrollDown
unmap j
unmapAll
" this is a comment
# this is also a comment</pre>
<a href="#" id="showCommands">Show available commands</a>.
</div>
</div>
<textarea id="keyMappings" type="text" tabIndex="1"></textarea>
</td>
</tr>
<tr>
<td class="caption">Custom search<br/>engines</td>
<td verticalAlign="top">
<div class="help">
<div class="example">
Add search-engine shortcuts to the Vomnibar. Format:<br/>
<pre>
a: http://a.com/?q=%s
b: http://b.com/?q=%s description
" this is a comment
# this is also a comment</pre>
%s is replaced with the search terms. <br/>
For search completion, see <a href="completion_engines.html" target="_blank">here</a>.
</div>
</div>
<textarea id="searchEngines"></textarea>
</td>
</tr>
<tbody id='advancedOptions'>
<tr>
<td colspan="2"><header>Advanced Options</header></td>
</tr>
<tr>
<td class="caption">Scroll step size</td>
<td>
<div class="help">
<div class="example">
The size for basic movements (usually j/k/h/l).
</div>
</div>
<input id="scrollStepSize" type="number" />px
</td>
</tr>
<tr id="linkHintCharactersContainer">
<td class="caption">Characters used<br/> for link hints</td>
<td verticalAlign="top">
<div class="help">
<div class="example">
The characters placed next to each link after typing "f" to enter link-hint mode.
</div>
</div>
<input id="linkHintCharacters" type="text" />
<div class="nonEmptyTextOption">
</td>
</tr>
<tr id="linkHintNumbersContainer">
<td class="caption">Characters used<br/> for link hints</td>
<td verticalAlign="top">
<div class="help">
<div class="example">
The characters placed next to each link after typing "f" to enter link-hint mode.
</div>
</div>
<input id="linkHintNumbers" type="text" />
<div class="nonEmptyTextOption">
</td>
</tr>
<tr>
<td class="caption" verticalAlign="top">Miscellaneous<br/>options</td>
<td verticalAlign="top" class="booleanOption">
<label>
<input id="smoothScroll" type="checkbox"/>
Use smooth scrolling
</label>
</td>
</tr>
<tr>
<td class="caption"></td>
<td verticalAlign="top" class="booleanOption">
<div class="help">
<div class="example">
In link-hint mode, this option lets you select a link by typing its text.
</div>
</div>
<label>
<input id="filterLinkHints" type="checkbox"/>
Use the link's name and characters for link-hint filtering
</label>
</td>
</tr>
<tr id="waitForEnterForFilteredHintsContainer">
<td class="caption"></td>
<td verticalAlign="top" class="booleanOption">
<div class="help">
<div class="example">
You activate the link with <tt>Enter</tt>, <em>always</em>; so you never accidentally type Vimium
commands.
</div>
</div>
<label>
<input id="waitForEnterForFilteredHints" type="checkbox"/>
Require <tt>Enter</tt> when filtering hints
</label>
</td>
</tr>
<tr>
<td class="caption"></td>
<td verticalAlign="top" class="booleanOption">
<div class="help">
<div class="example">
Prevent pages from focusing an input on load (e.g. Google, Bing, etc.).
</div>
</div>
<label>
<input id="grabBackFocus" type="checkbox"/>
Don't let pages steal the focus on load
</label>
</td>
</tr>
<tr>
<td class="caption"></td>
<td verticalAlign="top" class="booleanOption">
<div class="help">
<div class="example">
When enabled, the HUD will not be displayed in insert mode.
</div>
</div>
<label>
<input id="hideHud" type="checkbox"/>
Hide the Heads Up Display (HUD) in insert mode
</label>
</td>
</tr>
<tr>
<td class="caption"></td>
<td verticalAlign="top" class="booleanOption">
<div class="help">
<div class="example">
Switch back to plain find mode by using the <code>\R</code> escape sequence.
</div>
</div>
<label>
<input id="regexFindMode" type="checkbox"/>
Treat find queries as JavaScript regular expressions
</label>
</td>
</tr>
<tr>
<td class="caption"></td>
<td verticalAlign="top" class="booleanOption">
<div class="help">
<div class="example">
This forces the use of <code>en-US</code> QWERTY layout and
can be helpful for non-Latin keyboards.
</div>
</div>
<label>
<input id="ignoreKeyboardLayout" type="checkbox"/>
Ignore keyboard layout
</label>
</td>
</tr>
<tr>
<td class="caption">Previous patterns</td>
<td verticalAlign="top">
<div class="help">
<div class="example">
The "navigate to previous page" command uses these patterns to find the link to follow.
</div>
</div>
<input id="previousPatterns" type="text" />
<div class="nonEmptyTextOption">
</td>
</tr>
<tr>
<td class="caption">Next patterns</td>
<td verticalAlign="top">
<div class="help">
<div class="example">
The "navigate to next page" command uses these patterns to find the link to follow.
</div>
</div>
<input id="nextPatterns" type="text" />
<div class="nonEmptyTextOption">
</td>
</tr>
<tr>
<td class="caption">New tab URL</td>
<td verticalAlign="top">
<div class="help">
<div class="example">
The page to open with the "create new tab" command.
Set this to "<tt>pages/blank.html</tt>" for a blank page (except incognito mode).<br />
</div>
</div>
<input id="newTabUrl" type="text" />
<div class="nonEmptyTextOption">
</td>
</tr>
<tr>
<td class="caption">Default search<br/>engine</td>
<td verticalAlign="top">
<div class="help">
<div class="example">
The search engine to use in the Vomnibar <br> (e.g.: "https://duckduckgo.com/?q=").
</div>
</div>
<input id="searchUrl" type="text" />
<div class="nonEmptyTextOption">
</td>
</tr>
<tr>
<td class="caption">CSS for Vimium UI</td>
<td verticalAlign="top">
<div class="help">
<div class="example">
These styles are applied to link hints, the Vomnibar, the help dialog, the exclusions pop-up and the HUD.<br />
By default, this CSS is used to style the characters next to each link hint.<br/><br/>
These styles are used in addition to and take precedence over Vimium's
default styles.
</div>
</div>
<textarea id="userDefinedLinkHintCss" class="code" type="text"></textarea>
<div class="nonEmptyTextOption">
</td>
</tr>
<!-- Vimium Labs -->
<!--
Disabled. But we leave this code here as a template for the next time we need to introduce "Vimium Labs".
<tr>
<td colspan="2"><header>Vimium Labs</header></td>
</tr>
<tr>
<td class="caption"></td>
<td>
<div class="help">
<div class="example">
</div>
</div>
These features are experimental and may be changed or removed in future releases.
</td>
</tr>
<tr>
<td class="caption">Search weighting</td>
<td>
<div class="help">
<div class="example">
How prominent should suggestions be in the vomnibar?
<tt>0</tt> disables suggestions altogether.
</div>
</div>
<input id="omniSearchWeight" type="number" min="0.0" max="1.0" step="0.05" />(0 to 1)
</td>
</tr>
-->
<tr>
<td colspan="2"><header>Backup and Restore</header></td>
</tr>
<tr>
<td class="caption">Backup</td>
<td>
<div class="help">
<div class="example">
Click to backup your settings, or right-click and select <i>Save As</i>.
</div>
</div>
<a id="backupLink" download="vimium-options.json">Click to download backup</a>
</td>
</tr>
<tr>
<td class="caption">Restore</td>
<td>
<div class="help">
<div class="example">
Choose a backup file to restore, then click <i>Save Changes</i>, below, to confirm.
</div>
</div>
<input id="chooseFile" type="file" accept=".json" />
</td>
</tr>
</tbody>
</table>
</div>
<!-- Some extra space which is hidden underneath the footer. -->
<div id="endSpace"/>
<div id="footer">
<div id="footerWrapper">
<table id="footerTable">
<tr>
<td id="footerTableData">
<span id="helpText">
Type <strong>?</strong> to show the help dialog.
<br/>
Type <strong>Ctrl-Enter</strong> to save <i>all</i> options.
</span>
</td>
<td id="saveOptionsTableData" nowrap>
<button id="advancedOptionsButton"></button>
<button id="saveOptions" disabled="true">No Changes</button>
</td>
</tr>
</table>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,509 @@
const $ = id => document.getElementById(id);
const bgExclusions = chrome.extension.getBackgroundPage().Exclusions;
// We have to use Settings from the background page here (not Settings, directly) to avoid a race condition for
// the page popup. Specifically, we must ensure that the settings have been updated on the background page
// *before* the popup closes. This ensures that any exclusion-rule changes are in place before the page
// regains the focus.
const bgSettings = chrome.extension.getBackgroundPage().Settings;
//
// Class hierarchy for various types of option.
// Call "fetch" after constructing an Option object, to fetch it from localStorage.
class Option {
// Base class for all option classes.
// Abstract. Option does not define @populateElement or @readValueFromElement.
constructor(field, onUpdated) {
this.field = field;
this.onUpdated = onUpdated;
this.element = $(this.field);
this.element.addEventListener("change", this.onUpdated);
Option.all.push(this);
}
// Fetch a setting from localStorage, remember the @previous value and populate the DOM element.
// Return the fetched value.
fetch() {
this.populateElement(this.previous = bgSettings.get(this.field));
return this.previous;
}
// Write this option's new value back to localStorage, if necessary.
save() {
const value = this.readValueFromElement();
if (JSON.stringify(value) !== JSON.stringify(this.previous))
bgSettings.set(this.field, (this.previous = value));
}
restoreToDefault() {
bgSettings.clear(this.field);
return this.fetch();
}
static onSave(callback) {
this.onSaveCallbacks.push(callback);
}
// Static method.
static saveOptions() {
Option.all.map(option => option.save());
this.onSaveCallbacks.map((callback) => callback());
}
}
// Abstract method; only implemented in sub-classes.
// Populate the option's DOM element (@element) with the setting's current value.
// populateElement: (value) -> DO_SOMETHING
// Abstract method; only implemented in sub-classes.
// Extract the setting's new value from the option's DOM element (@element).
// readValueFromElement: -> RETURN_SOMETHING
Option.all = []; // Static. Array of all options.
Option.onSaveCallbacks = [];
class NumberOption extends Option {
populateElement(value) { this.element.value = value; }
readValueFromElement() { return parseFloat(this.element.value); }
}
class TextOption extends Option {
constructor(...args) {
super(...Array.from(args || []));
this.element.addEventListener("input", this.onUpdated);
}
populateElement(value) { this.element.value = value; }
readValueFromElement() { return this.element.value.trim(); }
}
class NonEmptyTextOption extends Option {
constructor(...args) {
super(...Array.from(args || []));
this.element.addEventListener("input", this.onUpdated);
}
populateElement(value) { this.element.value = value; }
// If the new value is not empty, then return it. Otherwise, restore the default value.
readValueFromElement() {
const value = this.element.value.trim();
if (value)
return value;
else
return this.restoreToDefault();
}
}
class CheckBoxOption extends Option {
populateElement(value) { this.element.checked = value; }
readValueFromElement() { return this.element.checked; }
}
class ExclusionRulesOption extends Option {
constructor(...args) {
super(...Array.from(args || []));
$("exclusionAddButton").addEventListener("click", event => {
this.addRule();
});
}
// Add a new rule, focus its pattern, scroll it into view, and return the newly-added element. On the
// options page, there is no current URL, so there is no initial pattern. This is the default. On the popup
// page (see ExclusionRulesOnPopupOption), the pattern is pre-populated based on the current tab's URL.
addRule(pattern) {
if (pattern == null)
pattern = "";
const element = this.appendRule({ pattern, passKeys: "" });
this.getPattern(element).focus();
const exclusionScrollBox = $("exclusionScrollBox");
exclusionScrollBox.scrollTop = exclusionScrollBox.scrollHeight;
this.onUpdated();
return element;
}
populateElement(rules) {
// For the case of restoring a backup, we first have to remove existing rules.
const exclusionRules = $("exclusionRules");
while (exclusionRules.rows[1]) { exclusionRules.deleteRow(1); }
for (let rule of rules)
this.appendRule(rule);
}
// Append a row for a new rule. Return the newly-added element.
appendRule(rule) {
let element;
const content = document.querySelector('#exclusionRuleTemplate').content;
const row = document.importNode(content, true);
for (let field of ["pattern", "passKeys"]) {
element = row.querySelector(`.${field}`);
element.value = rule[field];
for (let event of [ "input", "change" ])
element.addEventListener(event, this.onUpdated);
}
this.getRemoveButton(row).addEventListener("click", event => {
rule = event.target.parentNode.parentNode;
rule.parentNode.removeChild(rule);
this.onUpdated();
});
this.element.appendChild(row);
return this.element.children[this.element.children.length-1];
}
readValueFromElement() {
const rules =
Array.from(this.element.getElementsByClassName("exclusionRuleTemplateInstance")).map((element) => ({
pattern: this.getPattern(element).value.trim(),
passKeys: this.getPassKeys(element).value.trim()
}));
return rules.filter(rule => rule.pattern);
}
// Accessors for the three main sub-elements of an "exclusionRuleTemplateInstance".
getPattern(element) { return element.querySelector(".pattern"); }
getPassKeys(element) { return element.querySelector(".passKeys"); }
getRemoveButton(element) { return element.querySelector(".exclusionRemoveButton"); }
}
// ExclusionRulesOnPopupOption is ExclusionRulesOption, extended with some UI tweeks suitable for use in the
// page popup. This also differs from ExclusionRulesOption in that, on the page popup, there is always a URL
// (@url) associated with the current tab.
class ExclusionRulesOnPopupOption extends ExclusionRulesOption {
constructor(url, ...args) {
super(...Array.from(args || []));
this.url = url;
}
addRule() {
const element = super.addRule(this.generateDefaultPattern());
this.activatePatternWatcher(element);
// ExclusionRulesOption.addRule()/super() has focused the pattern. Here, focus the passKeys instead;
// because, in the popup, we already have a pattern, so the user is more likely to edit the passKeys.
this.getPassKeys(element).focus();
// Return element (for consistency with ExclusionRulesOption.addRule()).
return element;
}
populateElement(rules) {
let element;
super.populateElement(rules);
const elements = this.element.getElementsByClassName("exclusionRuleTemplateInstance");
for (element of Array.from(elements))
this.activatePatternWatcher(element);
let haveMatch = false;
for (element of Array.from(elements)) {
const pattern = this.getPattern(element).value.trim();
if (0 <= this.url.search(bgExclusions.RegexpCache.get(pattern))) {
haveMatch = true;
this.getPassKeys(element).focus();
} else {
element.style.display = 'none';
}
}
if (!haveMatch)
return this.addRule();
}
// Provide visual feedback (make it red) when a pattern does not match the current tab's URL.
activatePatternWatcher(element) {
const patternElement = element.children[0].firstChild;
patternElement.addEventListener("keyup", () => {
if (this.url.match(bgExclusions.RegexpCache.get(patternElement.value))) {
patternElement.title = (patternElement.style.color = "");
} else {
patternElement.style.color = "red";
patternElement.title = "Red text means that the pattern does not\nmatch the current URL.";
}
});
}
// Generate a default exclusion-rule pattern from a URL. This is then used to pre-populate the pattern on
// the page popup.
generateDefaultPattern() {
if (/^https?:\/\/./.test(this.url)) {
// The common use case is to disable Vimium at the domain level.
// Generate "https?://www.example.com/*" from "http://www.example.com/path/to/page.html".
// Note: IPV6 host addresses will contain "[" and "]" (which must be escaped).
const hostname = this.url.split("/",3).slice(1).join("/").replace("[", "\\[").replace("]", "\\]");
return "https?:/" + hostname + "/*";
} else if (/^[a-z]{3,}:\/\/./.test(this.url)) {
// Anything else which seems to be a URL.
return this.url.split("/",3).join("/") + "/*";
} else {
return this.url + "*";
}
}
}
const Options = {
exclusionRules: ExclusionRulesOption,
filterLinkHints: CheckBoxOption,
waitForEnterForFilteredHints: CheckBoxOption,
hideHud: CheckBoxOption,
keyMappings: TextOption,
linkHintCharacters: NonEmptyTextOption,
linkHintNumbers: NonEmptyTextOption,
newTabUrl: NonEmptyTextOption,
nextPatterns: NonEmptyTextOption,
previousPatterns: NonEmptyTextOption,
regexFindMode: CheckBoxOption,
ignoreKeyboardLayout: CheckBoxOption,
scrollStepSize: NumberOption,
smoothScroll: CheckBoxOption,
grabBackFocus: CheckBoxOption,
searchEngines: TextOption,
searchUrl: NonEmptyTextOption,
userDefinedLinkHintCss: NonEmptyTextOption
};
const initOptionsPage = function() {
const onUpdated = function() {
$("saveOptions").removeAttribute("disabled");
$("saveOptions").textContent = "Save Changes";
};
// Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints".
const maintainLinkHintsView = function() {
const hide = el => el.style.display = "none";
const show = el => el.style.display = "table-row";
if ($("filterLinkHints").checked) {
hide($("linkHintCharactersContainer"));
show($("linkHintNumbersContainer"));
show($("waitForEnterForFilteredHintsContainer"));
} else {
show($("linkHintCharactersContainer"));
hide($("linkHintNumbersContainer"));
hide($("waitForEnterForFilteredHintsContainer"));
}
};
const maintainAdvancedOptions = function() {
if (bgSettings.get("optionsPage_showAdvancedOptions")) {
$("advancedOptions").style.display = "table-row-group";
return $("advancedOptionsButton").textContent = "Hide Advanced Options";
} else {
$("advancedOptions").style.display = "none";
return $("advancedOptionsButton").textContent = "Show Advanced Options";
}
};
maintainAdvancedOptions();
const toggleAdvancedOptions = function(event) {
bgSettings.set("optionsPage_showAdvancedOptions", !bgSettings.get("optionsPage_showAdvancedOptions"));
maintainAdvancedOptions();
$("advancedOptionsButton").blur();
event.preventDefault();
};
const activateHelpDialog = function() {
HelpDialog.toggle({showAllCommandDetails: true});
};
const saveOptions = function() {
$("linkHintCharacters").value = $("linkHintCharacters").value.toLowerCase();
Option.saveOptions();
$("saveOptions").disabled = true;
$("saveOptions").textContent = "Saved";
};
$("saveOptions").addEventListener("click", saveOptions);
$("advancedOptionsButton").addEventListener("click", toggleAdvancedOptions);
$("showCommands").addEventListener("click", activateHelpDialog);
$("filterLinkHints").addEventListener("click", maintainLinkHintsView);
for (let element of Array.from(document.getElementsByClassName("nonEmptyTextOption"))) {
element.className = element.className + " example info";
element.textContent = "Leave empty to reset this option.";
}
window.onbeforeunload = function() {
if (!$("saveOptions").disabled)
return "You have unsaved changes to options.";
};
document.addEventListener("keyup", function(event) {
if (event.ctrlKey && (event.keyCode === 13)) {
if (document && document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
return saveOptions();
}
}
});
// Populate options. The constructor adds each new object to "Option.all".
for (let name of Object.keys(Options)) {
const type = Options[name];
const option = new type(name, onUpdated);
option.fetch();
}
maintainLinkHintsView();
};
const initPopupPage = function() {
chrome.tabs.query({ active: true, currentWindow: true }, function(...args) {
const [tab] = Array.from(args[0]);
let exclusions = null;
document.getElementById("optionsLink").setAttribute("href", chrome.runtime.getURL("pages/options.html"));
const tabPorts = chrome.extension.getBackgroundPage().portsForTab[tab.id];
if (!tabPorts || !(Object.keys(tabPorts).length > 0)) {
// The browser has disabled Vimium on this page. Place a message explaining this into the popup.
document.body.innerHTML = `\
<div style="width: 400px; margin: 5px;">
<p style="margin-bottom: 5px;">
Vimium is not running on this page.
</p>
<p style="margin-bottom: 5px;">
Your browser does not run web extensions like Vimium on certain pages,
usually for security reasons.
</p>
<p>
Unless your browser's developers change their policy, then unfortunately it is not possible to make Vimium (or any other
web extension, for that matter) work on this page.
</p>
</div>\
`;
return;
}
// As the active URL, we choose the most recently registered URL from a frame in the tab, or the tab's own
// URL.
const url = chrome.extension.getBackgroundPage().urlForTab[tab.id] || tab.url;
const updateState = function() {
const rule = bgExclusions.getRule(url, exclusions.readValueFromElement());
$("state").innerHTML = "Vimium will " +
(rule && rule.passKeys ?
`exclude <span class='code'>${rule.passKeys}</span>`
: (rule ? "be disabled" : "be enabled"));
};
const onUpdated = function() {
$("helpText").innerHTML = "Type <strong>Ctrl-Enter</strong> to save and close.";
$("saveOptions").removeAttribute("disabled");
$("saveOptions").textContent = "Save Changes";
if (exclusions)
return updateState();
};
const saveOptions = function() {
Option.saveOptions();
$("saveOptions").textContent = "Saved";
$("saveOptions").disabled = true;
};
$("saveOptions").addEventListener("click", saveOptions);
document.addEventListener("keyup", function(event) {
if (event.ctrlKey && (event.keyCode === 13)) {
saveOptions();
window.close();
}
});
// Populate options. Just one, here.
exclusions = new ExclusionRulesOnPopupOption(url, "exclusionRules", onUpdated);
exclusions.fetch();
updateState();
document.addEventListener("keyup", updateState);
});
// Install version number.
const manifest = chrome.runtime.getManifest();
$("versionNumber").textContent = manifest.version;
};
//
// Initialization.
document.addEventListener("DOMContentLoaded", function() {
DomUtils.injectUserCss(); // Manually inject custom user styles.
const xhr = new XMLHttpRequest();
xhr.open('GET', chrome.extension.getURL('pages/exclusions.html'), true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
$("exclusionScrollBox").innerHTML = xhr.responseText;
switch (location.pathname) {
case "/pages/options.html": initOptionsPage(); break;
case "/pages/popup.html": initPopupPage(); break;
}
}
};
xhr.send();
});
//
// Backup and restore.
if (global.DomUtils) { // global.DomUtils is not defined when running our tests.
DomUtils.documentReady(function() {
// Only initialize backup/restore on the options page (not the popup).
if (location.pathname !== "/pages/options.html")
return;
let restoreSettingsVersion = null;
const populateBackupLinkUrl = function() {
const backup = {settingsVersion: bgSettings.get("settingsVersion")};
for (let option of Array.from(Option.all))
backup[option.field] = option.readValueFromElement();
// Create the blob in the background page so it isn't garbage collected when the page closes in FF.
const bgWin = chrome.extension.getBackgroundPage();
const blob = new bgWin.Blob([ JSON.stringify(backup, null, 2) + "\n" ]);
$("backupLink").href = bgWin.URL.createObjectURL(blob);
};
$("backupLink").addEventListener("mousedown", populateBackupLinkUrl, true);
$("chooseFile").addEventListener("change", function(event) {
if (document.activeElement)
document.activeElement.blur();
const files = event.target.files;
if (files.length === 1) {
const file = files[0];
const reader = new FileReader;
reader.readAsText(file);
reader.onload = function(event) {
let backup;
try {
backup = JSON.parse(reader.result);
} catch (error) {
alert("Failed to parse Vimium backup.");
return;
}
if ("settingsVersion" in backup)
restoreSettingsVersion = backup["settingsVersion"];
for (let option of Array.from(Option.all)) {
if (option.field in backup) {
option.populateElement(backup[option.field]);
option.onUpdated();
}
}
};
}
});
Option.onSave(function() {
// If we're restoring a backup, then restore the backed up settingsVersion.
if (restoreSettingsVersion != null) {
bgSettings.set("settingsVersion", restoreSettingsVersion);
restoreSettingsVersion = null;
}
// Reset the restore-backup input.
$("chooseFile").value = "";
// We need to apply migrations in case we are restoring an old backup.
bgSettings.applyMigrations();
});
});
}
// Exported for use by our tests.
global.Options = Options
global.isVimiumOptionsPage = true

View file

@ -0,0 +1,88 @@
<html>
<head>
<link rel="stylesheet" type="text/css" href="options.css">
<link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css">
<style>
* {
margin: 0px;
padding: 0px;
}
#helpText, #optionsLink, #state {
font-family : "Helvetica Neue", "Helvetica", "Arial", sans-serif;
font-size: 12px;
}
#helpText, #stateLine, #state { color: #979ca0; }
#saveOptions {
margin-top: 5px; /* Match #exclusionAddButton */
margin-left: 5px;
float: right;
}
#state {
padding-left: 5px;
background: #f5f5f5;
width: 100%;
border-bottom: 1px solid #979ca0;
margin: 0px;
}
/* These are overridden from options.css. */
#endSpace, #footerWrapper { width: 500px; }
#footerWrapper { margin-left: 0px; }
/* Make exclusionScrollBox smaller than on the options page, because there are likely to be fewer
matching rules, and the popup obscures the underlying page.
*/
#exclusionScrollBox {
max-height: 124px;
min-height: 124px;
}
#endSpace { /* Leave space for the fixed footer. */
min-height: 40px;
max-height: 40px;
}
</style>
<script src="../lib/utils.js"></script>
<script src="../lib/dom_utils.js"></script>
<script src="../lib/settings.js"></script>
<script src="options.js"></script>
</head>
<body class="vimiumBody">
<div id="state"></div>
<div id="exclusionScrollBox">
<!-- Populated from exclusions.html by options.js. -->
</div>
<!-- Some extra space which is hidden underneath the footer. -->
<div id="endSpace"/>
<div id="footer">
<div id="footerWrapper">
<table>
<tr>
<td id="stateLine" style="width: 99%">
<span id="helpText">These are the rules matching this page.</span>
<br/>
<span id="versionAndOptions">
<a id="optionsLink" target="_blank" tabindex="99999">Options</a> (version <span id="versionNumber"></span>).
</span>
</td>
<td valign="top">
<button id="exclusionAddButton">Add Rule</button>
</td>
<td valign="top">
<button id="saveOptions" disabled="true">No Changes</button>
</td>
</tr>
</table>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,61 @@
// Fetch the Vimium secret, register the port received from the parent window, and stop listening for messages
// on the window object. vimiumSecret is accessible only within the current instance of Vimium. So a
// malicious host page trying to register its own port can do no better than guessing.
var registerPort = function(event) {
chrome.storage.local.get("vimiumSecret", function({vimiumSecret: secret}) {
if ((event.source !== window.parent) || (event.data !== secret))
return;
UIComponentServer.portOpen(event.ports[0]);
window.removeEventListener("message", registerPort);
});
};
window.addEventListener("message", registerPort);
var UIComponentServer = {
ownerPagePort: null,
handleMessage: null,
portOpen(ownerPagePort) {
this.ownerPagePort = ownerPagePort;
this.ownerPagePort.onmessage = event => {
if (this.handleMessage)
return this.handleMessage(event);
};
this.registerIsReady();
},
registerHandler(handleMessage) {
this.handleMessage = handleMessage;
},
postMessage(message) {
if (this.ownerPagePort)
this.ownerPagePort.postMessage(message);
},
hide() { this.postMessage("hide"); },
// We require both that the DOM is ready and that the port has been opened before the UI component is ready.
// These events can happen in either order. We count them, and notify the content script when we've seen
// both.
registerIsReady: (function() {
let uiComponentIsReadyCount;
if (document.readyState === "loading") {
window.addEventListener("DOMContentLoaded", () => UIComponentServer.registerIsReady());
uiComponentIsReadyCount = 0;
} else {
uiComponentIsReadyCount = 1;
}
return function() {
if (++uiComponentIsReadyCount === 2) {
if (window.frameId != null)
this.postMessage({name: "setIframeFrameId", iframeFrameId: window.frameId});
this.postMessage("uiComponentIsReady");
}
};
})()
};
global.UIComponentServer = UIComponentServer;

View file

@ -0,0 +1,205 @@
/* Vomnibar CSS */
#vomnibar ol, #vomnibar ul {
list-style: none;
display: none;
}
#vomnibar {
display: block;
position: fixed;
width: calc(100% - 20px); /* adjusted to keep border radius and box-shadow visible*/
/*min-width: 400px;
top: 70px;
left: 50%;*/
top: 8px;
left: 8px;
/*margin: 0 0 0 -40%;*/
font-family: sans-serif;
background: #F1F1F1;
text-align: left;
border-radius: 4px;
box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8);
border: 1px solid #aaa;
z-index: 2139999999; /* One less than hint markers and the help dialog (see ../content_scripts/vimium.css). */
}
#vomnibar input {
color: #000;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 20px;
height: 34px;
margin-bottom: 0;
padding: 4px;
background-color: white;
border-radius: 3px;
border: 1px solid #E8E8E8;
box-shadow: #444 0px 0px 1px;
width: 100%;
outline: none;
box-sizing: border-box;
}
#vomnibar .vomnibarSearchArea {
display: block;
padding: 10px;
background-color: #F1F1F1;
border-radius: 4px 4px 0 0;
border-bottom: 1px solid #C6C9CE;
}
#vomnibar ul {
background-color: white;
border-radius: 0 0 4px 4px;
list-style: none;
padding: 10px 0;
padding-top: 0;
}
#vomnibar li {
border-bottom: 1px solid #ddd;
line-height: 1.1em;
padding: 7px 10px;
font-size: 16px;
color: black;
position: relative;
display: list-item;
margin: auto;
}
#vomnibar li:last-of-type {
border-bottom: none;
}
#vomnibar li .vomnibarTopHalf, #vomnibar li .vomnibarBottomHalf {
display: block;
overflow: hidden;
}
#vomnibar li .vomnibarBottomHalf {
font-size: 15px;
margin-top: 3px;
padding: 2px 0;
}
#vomnibar li .vomnibarIcon {
padding: 0 13px 0 6px;
vertical-align: bottom;
}
#vomnibar li .vomnibarSource {
color: #777;
margin-right: 4px;
}
#vomnibar li .vomnibarRelevancy {
position: absolute;
right: 0;
top: 0;
padding: 5px;
background-color: white;
color: black;
font-family: monospace;
width: 100px;
overflow: hidden;
}
#vomnibar li .vomnibarUrl {
white-space: nowrap;
color: #224684;
}
#vomnibar li .vomnibarMatch {
font-weight: bold;
color: black;
}
#vomnibar li em, #vomnibar li .vomnibarTitle {
color: black;
margin-left: 4px;
font-weight: normal;
}
#vomnibar li em { font-style: italic; }
#vomnibar li em .vomnibarMatch, #vomnibar li .vomnibarTitle .vomnibarMatch {
color: #333;
}
#vomnibar li.vomnibarSelected {
background-color: #BBCEE9;
font-weight: normal;
}
#vomnibarInput::selection {
/* This is the light grey color of the vomnibar border. */
/* background-color: #F1F1F1; */
/* This is the light blue color of the vomnibar selected item. */
/* background-color: #BBCEE9; */
/* This is a considerably lighter blue than Vimium blue, which seems softer
* on the eye for this purpose. */
background-color: #E6EEFB;
}
.vomnibarInsertText {
}
.vomnibarNoInsertText {
visibility: hidden;
}
/* Dark Vomnibar */
@media (prefers-color-scheme: dark) {
#vomnibar {
border: 1px solid rgba(0, 0, 0, 0.7);
border-radius: 6px;
}
#vomnibar .vomnibarSearchArea, #vomnibar {
background-color: #35363a;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
#vomnibar input {
background-color: #202124;
color: white;
border: none;
}
#vomnibar ul {
background-color: #202124;
}
#vomnibar li {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
#vomnibar li.vomnibarSelected {
background-color: #37383a;
}
#vomnibar li .vomnibarUrl {
white-space: nowrap;
color: #5ca1f7;
}
#vomnibar li em,
#vomnibar li .vomnibarTitle {
color: white;
}
#vomnibar li .vomnibarSource {
color: #9aa0a6;
}
#vomnibar li .vomnibarMatch {
color: white;
}
#vomnibar li em .vomnibarMatch,
#vomnibar li .vomnibarTitle .vomnibarMatch {
color: white;
}
}

View file

@ -0,0 +1,23 @@
<html>
<head>
<title>Vomnibar</title>
<script type="text/javascript" src="../lib/utils.js"></script>
<script type="text/javascript" src="../lib/settings.js"></script>
<script type="text/javascript" src="../lib/keyboard_utils.js"></script>
<script type="text/javascript" src="../lib/dom_utils.js"></script>
<script type="text/javascript" src="../lib/handler_stack.js"></script>
<script type="text/javascript" src="../lib/clipboard.js"></script>
<script type="text/javascript" src="ui_component_server.js"></script>
<script type="text/javascript" src="vomnibar.js"></script>
<link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" />
<link rel="stylesheet" type="text/css" href="vomnibar.css" />
</head>
<body>
<div id="vomnibar">
<div class="vomnibarSearchArea">
<input id="vomnibarInput" type="text" autocomplete="off">
</div>
<ul></ul>
</div>
</body>
</html>

View file

@ -0,0 +1,456 @@
//
// This controls the contents of the Vomnibar iframe. We use an iframe to avoid changing the selection on the
// page (useful for bookmarklets), ensure that the Vomnibar style is unaffected by the page, and simplify key
// handling in vimium_frontend.js
//
const Vomnibar = {
vomnibarUI: null, // the dialog instance for this window
getUI() { return this.vomnibarUI; },
completers: {},
getCompleter(name) {
if (!this.completers[name])
this.completers[name] = new BackgroundCompleter(name);
return this.completers[name];
},
activate(userOptions) {
const options = {
completer: "omni",
query: "",
newTab: false,
selectFirst: false,
keyword: null
};
Object.assign(options, userOptions);
Object.assign(options, {refreshInterval: options.completer === "omni" ? 150 : 0});
const completer = this.getCompleter(options.completer);
if (this.vomnibarUI == null)
this.vomnibarUI = new VomnibarUI();
completer.refresh(this.vomnibarUI);
this.vomnibarUI.setInitialSelectionValue(options.selectFirst ? 0 : -1);
this.vomnibarUI.setCompleter(completer);
this.vomnibarUI.setRefreshInterval(options.refreshInterval);
this.vomnibarUI.setForceNewTab(options.newTab);
this.vomnibarUI.setQuery(options.query);
this.vomnibarUI.setKeyword(options.keyword);
this.vomnibarUI.update(true);
},
hide() {
if (this.vomnibarUI)
this.vomnibarUI.hide();
},
onHidden() {
if (this.vomnibarUI)
this.vomnibarUI.onHidden()
}
};
class VomnibarUI {
constructor() {
this.onKeyEvent = this.onKeyEvent.bind(this);
this.onInput = this.onInput.bind(this);
this.update = this.update.bind(this);
this.refreshInterval = 0;
this.onHiddenCallback = null;
this.initDom();
}
setQuery(query) { this.input.value = query; }
setKeyword(keyword) { this.customSearchMode = keyword; }
setInitialSelectionValue(initialSelectionValue) {
this.initialSelectionValue = initialSelectionValue;
}
setRefreshInterval(refreshInterval) {
this.refreshInterval = refreshInterval;
}
setForceNewTab(forceNewTab) {
this.forceNewTab = forceNewTab;
}
setCompleter(completer) {
this.completer = completer;
this.reset();
}
setKeywords(keywords) {
this.keywords = keywords;
}
// The sequence of events when the vomnibar is hidden is as follows:
// 1. Post a "hide" message to the host page.
// 2. The host page hides the vomnibar.
// 3. When that page receives the focus, and it posts back a "hidden" message.
// 3. Only once the "hidden" message is received here is any required action invoked (in onHidden).
// This ensures that the vomnibar is actually hidden before any new tab is created, and avoids flicker after
// opening a link in a new tab then returning to the original tab (see #1485).
hide(onHiddenCallback = null) {
this.onHiddenCallback = onHiddenCallback;
this.input.blur();
UIComponentServer.postMessage("hide");
this.reset();
}
onHidden() {
if (typeof this.onHiddenCallback === 'function') {
this.onHiddenCallback();
}
this.onHiddenCallback = null;
return this.reset();
}
reset() {
this.clearUpdateTimer();
this.completionList.style.display = "";
this.input.value = "";
this.completions = [];
this.previousInputValue = null;
this.customSearchMode = null;
this.selection = this.initialSelectionValue;
this.keywords = [];
this.seenTabToOpenCompletionList = false;
if (this.completer != null) {
this.completer.reset();
}
}
updateSelection() {
// For custom search engines, we suppress the leading term (e.g. the "w" of "w query terms") within the
// vomnibar input.
if (this.lastResponse.isCustomSearch && (this.customSearchMode == null)) {
const queryTerms = this.input.value.trim().split(/\s+/);
this.customSearchMode = queryTerms[0];
this.input.value = queryTerms.slice(1).join(" ");
}
// For suggestions for custom search engines, we copy the suggested text into the input when the item is
// selected, and revert when it is not. This allows the user to select a suggestion and then continue
// typing.
if ((0 <= this.selection) && (this.completions[this.selection].insertText != null)) {
if (this.previousInputValue == null) { this.previousInputValue = this.input.value; }
this.input.value = this.completions[this.selection].insertText;
} else if (this.previousInputValue != null) {
this.input.value = this.previousInputValue;
this.previousInputValue = null;
}
// Highlight the selected entry, and only the selected entry.
for (let i = 0, end = this.completionList.children.length; i < end; i++) {
this.completionList.children[i].className = (i === this.selection ? "vomnibarSelected" : "");
}
}
// Returns the user's action ("up", "down", "tab", etc, or null) based on their keypress. We support the
// arrow keys and various other shortcuts, and this function hides the event-decoding complexity.
actionFromKeyEvent(event) {
const key = KeyboardUtils.getKeyChar(event);
// Handle <Enter> on "keypress", and other events on "keydown"; this avoids interence with CJK translation
// (see #2915 and #2934).
if ((event.type === "keypress") && (key !== "enter")) { return null; }
if ((event.type === "keydown") && (key === "enter")) { return null; }
if (KeyboardUtils.isEscape(event)) {
return "dismiss";
} else if ((key === "up") ||
(event.shiftKey && (event.key === "Tab")) ||
(event.ctrlKey && ((key === "k") || (key === "p")))) {
return "up";
} else if ((event.key === "Tab") && !event.shiftKey) {
return "tab";
} else if ((key === "down") ||
(event.ctrlKey && ((key === "j") || (key === "n")))) {
return "down";
} else if (event.ctrlKey && (event.key === "Enter")) {
return "ctrl-enter";
} else if (event.key === "Enter") {
return "enter";
} else if ((event.key === "Delete") && event.shiftKey && !event.ctrlKey && !event.altKey) {
return "remove";
} else if (KeyboardUtils.isBackspace(event)) {
return "delete";
}
return null;
}
onKeyEvent(event) {
let action, completion;
this.lastAction = (action = this.actionFromKeyEvent(event));
if (!action)
return true; // pass through
const openInNewTab = this.forceNewTab || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
if (action === "dismiss") {
this.hide();
} else if ([ "tab", "down" ].includes(action)) {
if ((action === "tab") &&
(this.completer.name === "omni") &&
!this.seenTabToOpenCompletionList &&
(this.input.value.trim().length === 0)) {
this.seenTabToOpenCompletionList = true;
this.update(true);
} else if (this.completions.length > 0) {
this.selection += 1;
if (this.selection === this.completions.length)
this.selection = this.initialSelectionValue;
this.updateSelection();
}
} else if (action === "up") {
this.selection -= 1;
if (this.selection < this.initialSelectionValue)
this.selection = this.completions.length - 1;
this.updateSelection();
} else if (action === "enter") {
const c = this.completions[this.selection];
const isCustomSearchPrimarySuggestion = c && c.isPrimarySuggestion &&
this.lastResponse.engine && this.lastResponse.engine.searchUrl;
if ((this.selection === -1) || isCustomSearchPrimarySuggestion) {
let query = this.input.value.trim();
// <Enter> on an empty query is a no-op.
if (!(query.length > 0))
return;
// First case (@selection == -1).
// If the user types something and hits enter without selecting a completion from the list, then:
// - If a search URL has been provided, then use it. This is custom search engine request.
// - Otherwise, send the query to the background page, which will open it as a URL or create a
// default search, as appropriate.
//
// Second case (isCustomSearchPrimarySuggestion).
// Alternatively, the selected completion could be the primary selection for a custom search engine.
// Because the the suggestions are updated asynchronously in omni mode, the user may have typed more
// text than that which is included in the URL associated with the primary suggestion. Therefore, to
// avoid a race condition, we construct the query from the actual contents of the input (query).
if (isCustomSearchPrimarySuggestion)
query = Utils.createSearchUrl(query, this.lastResponse.engine.searchUrl);
this.hide(() => Vomnibar.getCompleter().launchUrl(query, openInNewTab));
} else {
completion = this.completions[this.selection];
this.hide(() => completion.performAction(openInNewTab));
}
} else if (action === "ctrl-enter") {
// Populate the vomnibar with the current selection's URL.
if (!this.customSearchMode && (this.selection >= 0)) {
if (this.previousInputValue == null) { this.previousInputValue = this.input.value; }
this.input.value = this.completions[this.selection] != null ? this.completions[this.selection].url : undefined;
this.input.scrollLeft = this.input.scrollWidth;
}
} else if (action === "delete") {
if (this.customSearchMode && (this.input.selectionEnd === 0)) {
// Normally, with custom search engines, the keyword (e,g, the "w" of "w query terms") is suppressed.
// If the cursor is at the start of the input, then reinstate the keyword (the "w").
this.input.value = this.customSearchMode + this.input.value.trimStart();
this.input.selectionStart = (this.input.selectionEnd = this.customSearchMode.length);
this.customSearchMode = null;
this.update(true);
} else if (this.seenTabToOpenCompletionList && (this.input.value.trim().length === 0)) {
this.seenTabToOpenCompletionList = false;
this.update(true);
} else {
return true; // Do not suppress event.
}
} else if ((action === "remove") && (0 <= this.selection)) {
completion = this.completions[this.selection];
console.log(completion);
}
// It seems like we have to manually suppress the event here and still return true.
event.stopImmediatePropagation();
event.preventDefault();
return true;
}
// Return the background-page query corresponding to the current input state. In other words, reinstate any
// search engine keyword which is currently being suppressed, and strip any prompted text.
getInputValueAsQuery() {
return ((this.customSearchMode != null) ? this.customSearchMode + " " : "") + this.input.value;
}
updateCompletions(callback = null) {
return this.completer.filter({
query: this.getInputValueAsQuery(),
seenTabToOpenCompletionList: this.seenTabToOpenCompletionList,
callback: lastResponse => {
this.lastResponse = lastResponse;
const { results } = this.lastResponse;
this.completions = results;
this.selection = (this.completions[0] != null ? this.completions[0].autoSelect : undefined) ? 0 : this.initialSelectionValue;
// Update completion list with the new suggestions.
this.completionList.innerHTML = this.completions.map(completion => `<li>${completion.html}</li>`).join("");
this.completionList.style.display = this.completions.length > 0 ? "block" : "";
this.selection = Math.min(this.completions.length - 1, Math.max(this.initialSelectionValue, this.selection));
this.updateSelection();
if (callback)
return callack();
}
});
}
onInput() {
let updateSynchronously;
this.seenTabToOpenCompletionList = false;
this.completer.cancel();
if ((0 <= this.selection) && this.completions[this.selection].customSearchMode && !this.customSearchMode) {
this.customSearchMode = this.completions[this.selection].customSearchMode;
updateSynchronously = true;
}
// If the user types, then don't reset any previous text, and reset the selection.
if (this.previousInputValue != null) {
this.previousInputValue = null;
this.selection = -1;
}
return this.update(updateSynchronously);
}
clearUpdateTimer() {
if (this.updateTimer != null) {
window.clearTimeout(this.updateTimer);
this.updateTimer = null;
}
}
shouldActivateCustomSearchMode() {
const queryTerms = this.input.value.trimStart().split(/\s+/);
return (1 < queryTerms.length) && Array.from(this.keywords).includes(queryTerms[0]) && !this.customSearchMode;
}
update(updateSynchronously, callback = null) {
// If the query text becomes a custom search (the user enters a search keyword), then we need to force a
// synchronous update (so that the state is updated immediately).
if (updateSynchronously == null) { updateSynchronously = false; }
if (!updateSynchronously) { updateSynchronously = this.shouldActivateCustomSearchMode(); }
if (updateSynchronously) {
this.clearUpdateTimer();
this.updateCompletions(callback);
} else if ((this.updateTimer == null)) {
// Update asynchronously for a better user experience, and to take some load off the CPU (not every
// keystroke will cause a dedicated update).
this.updateTimer = Utils.setTimeout(this.refreshInterval, () => {
this.updateTimer = null;
return this.updateCompletions(callback);
});
}
this.input.focus();
}
initDom() {
this.box = document.getElementById("vomnibar");
this.input = this.box.querySelector("input");
this.input.addEventListener("input", this.onInput);
this.input.addEventListener("keydown", this.onKeyEvent);
this.input.addEventListener("keypress", this.onKeyEvent);
this.completionList = this.box.querySelector("ul");
this.completionList.style.display = "";
window.addEventListener("focus", () => this.input.focus());
// A click in the vomnibar itself refocuses the input.
this.box.addEventListener("click", event => {
this.input.focus();
return event.stopImmediatePropagation();
});
// A click anywhere else hides the vomnibar.
document.addEventListener("click", () => this.hide());
}
}
//
// Sends requests to a Vomnibox completer on the background page.
//
class BackgroundCompleter {
// The "name" is the background-page completer to connect to: "omni", "tabs", or "bookmarks".
constructor(name) {
// These are the actions we can perform when the user selects a result.
this.name = name;
this.completionActions = {
navigateToUrl(url) { return openInNewTab => Vomnibar.getCompleter().launchUrl(url, openInNewTab); },
switchToTab(tabId) { return () => chrome.runtime.sendMessage({handler: "selectSpecificTab", id: tabId}); }
};
this.port = chrome.runtime.connect({name: "completions"});
this.messageId = null;
this.reset();
this.port.onMessage.addListener(msg => {
switch (msg.handler) {
case "keywords":
this.keywords = msg.keywords;
return this.lastUI.setKeywords(this.keywords);
case "completions":
if (msg.id === this.messageId) {
// The result objects coming from the background page will be of the form:
// { html: "", type: "", url: "", ... }
// Type will be one of [tab, bookmark, history, domain, search], or a custom search engine description.
for (let result of msg.results) {
Object.assign(result, {
performAction:
result.type === "tab" ?
this.completionActions.switchToTab(result.tabId) :
this.completionActions.navigateToUrl(result.url)
});
}
// Handle the message, but only if it hasn't arrived too late.
return this.mostRecentCallback(msg);
}
break;
}
});
}
filter(request) {
const { query, callback } = request;
this.mostRecentCallback = callback;
this.port.postMessage(Object.assign(request, {
handler: "filter",
name: this.name,
id: (this.messageId = Utils.createUniqueId()),
queryTerms: query.trim().split(/\s+/).filter(s => 0 < s.length),
// We don't send these keys.
callback: null
}));
}
reset() {
this.keywords = [];
}
refresh(lastUI) {
this.lastUI = lastUI;
this.reset();
return this.port.postMessage({name: this.name, handler: "refresh"});
}
cancel() {
// Inform the background completer that it may (should it choose to do so) abandon any pending query
// (because the user is typing, and there will be another query along soon).
this.port.postMessage({name: this.name, handler: "cancel"});
}
launchUrl(url, openInNewTab) {
// If the URL is a bookmarklet (so, prefixed with "javascript:"), then we always open it in the current
// tab.
if (openInNewTab)
openInNewTab = !Utils.hasJavascriptPrefix(url);
chrome.runtime.sendMessage({
handler: openInNewTab ? "openUrlInNewTab" : "openUrlInCurrentTab",
url
});
}
}
UIComponentServer.registerHandler(function(event) {
switch (event.data.name != null ? event.data.name : event.data) {
case "hide": Vomnibar.hide(); break;
case "hidden": Vomnibar.onHidden(); break;
case "activate": Vomnibar.activate(event.data); break;
}
});
document.addEventListener("DOMContentLoaded", function() {
DomUtils.injectUserCss(); // Manually inject custom user styles.
});
global.Vomnibar = Vomnibar;

View file

@ -0,0 +1,660 @@
/*
* This contains the definition of the completers used for the Vomnibox's suggestion UI. A complter will take
* a query (whatever the user typed into the Vomnibox) and return a list of matches, e.g. bookmarks, domains,
* URLs from history.
*
* The Vomnibox frontend script makes a "filterCompleter" request to the background page, which in turn calls
* filter() on each these completers.
*
* A completer is a class which has two functions:
* - refresh(): refreshes the completer's data source (e.g. refetches the list of bookmarks from Chrome).
* - filter(query, callback): "query" will be whatever the user typed into the Vomnibox. "callback" is a
* function which will be invoked with a list of LazyCompletionResults as its first argument.
*
* A completer's filter() function returns a list of LazyCompletionResults. This contains a relevancy score
* for the result, as well as a function to build the full result (e.g. the HTML representing this result).
*
* The MultiCompleter collects a big list of LazyCompletionResults from many completers by calling each of
* their filter functions in turn, sorts the results by relevancy, and then calls build() on the top N
* results. This allows us to avoid generating HTML for all of the results we're not going to use.
* The objects returned from build() are sent to the Vomnibox frontend script to be shown in the UI.
*/
var completion = (function() {
// This is a development option, useful for tuning the ranking of vomnibox results.
var showRelevancyScoreInResults = false;
/*
* An object which contains a relevancy score for the given completion, and a function which can be
* invoked to build its HTML.
* Calling build() should return an object of the form:
* { html: "", action: { functionName: "", args: [] } }
* This object is eventually sent back to the Vomnibox frontend script. "action" contains the action to
* be performed by the frontend script if this result is chosen (user selects it and hits enter).
* "action" includes the function the frontendScript should execute (e.g. "navigateToUrl") along with any
* arguments (like the URL).
* "html" is the HTML representation of this result, with some characters emphasized to higlight the query.
*
* This is called "lazy" because it takes in a function to lazily compute a result's html. That operation
* can be kind of expensive, so you only want to do it to the top completion results, after you've sorted
* them by relevancy.
*/
var LazyCompletionResult = function(relevancy, buildFunction) {
this.relevancy = relevancy;
this.build = buildFunction;
}
/*
* A completer which takes in a list of keyword commands (like "wiki" for "search wikipedia") and will
* decide if your query is a command, a URL that you want to visit, or a search term.
*/
var SmartKeywordCompleter = Class.extend({
/*
* - commands: a list of commands of the form: { keyword: [title, url] }, e.g.:
* { "wiki ": ["Wikipedia (en)", "http://en.wikipedia.org/wiki/%s" ]
*/
init: function(commands) {
this.commands = commands || {};
this.commandKeys = Object.keys(commands);
},
refresh: function() { },
/** Returns the suggestions matching the user-defined commands */
getCommandSuggestions: function(query, suggestions) {
return this.commandKeys.filter(function(cmd) { return query.indexOf(cmd) == 0 }).map(function(cmd) {
var term = query.slice(cmd.length);
var desc = this.commands[cmd][0];
var pattern = this.commands[cmd][1];
var url = (typeof pattern == "function") ? pattern(term) : pattern.replace(/%s/g, term);
// this will appear even before the URL/search suggestion
var relevancy = -2;
return new LazyCompletionResult(relevancy, function() {
return {
html: createCompletionHtml(desc, term, null, relevancy),
action: { functionName: "navigateToUrl", args: [utils.createFullUrl(url)] },
}})
}.proxy(this));
},
/** Checks if the input is a URL. If yes, returns a suggestion to visit it. If no, returns a suggestion
* to start a web search. */
getUrlOrSearchSuggestion: function(query, suggestions) {
// trim query
query = query.replace(/^\s+|\s+$/g, '');
var isUrl = utils.isUrl(query);
var relevancy = -1;
return new LazyCompletionResult(relevancy, function() {
return {
html: createCompletionHtml(isUrl ? "goto" : "search", query, null, relevancy),
action: { functionName: "navigateToUrl",
args: isUrl ? [utils.createFullUrl(query)] : [utils.createSearchUrl(query)] }
}});
},
filter: function(query, callback) {
suggestions = this.getCommandSuggestions(query);
suggestions.push(this.getUrlOrSearchSuggestion(query));
callback(suggestions);
}
});
/*
* A generic asynchronous completer which is used by completers which have asynchronous data sources,
* like history or bookmarks.
*/
var AsyncCompleter = Class.extend({
init: function() {
this.id = utils.createUniqueId();
this.reset();
this.resultsReady = this.fallbackReadyCallback = function(results) { this.completions = results; }
},
reset: function() {
fuzzyMatcher.invalidateFilterCache(this.id);
this.completions = null;
},
/*
* This creates an intermediate representation of a completion which will later be called with a specific
* query.
* - type: the type of item we're completing against, e.g. "bookmark", "history", "tab".
* - item: the item itself. This should include a url and title property (Chrome's bookmark, history
* and tab objects include both of these).
* - action: the action to take in the Vomnibox frontend
*
* It's used to save us work -- we call this on every bookmark in your bookmarks list when we first fetch
* them, for instance, and we don't want to do some the same work again every time a new query is
* processed.
*
* TODO(philc): It would be nice if this could be removed; it's confusing.
*/
createUnrankedCompletion: function(type, item, action) {
var url = item.url;
var completionString = [type, url, item.title].join(" ")
action = action || { functionName: "navigateToUrl", args: [url] };
var displayUrl = this.stripTrailingSlash(url);
function createLazyCompletion(query) {
// We want shorter URLs (i.e. top level domains) to rank more highly.
var relevancy = url.length / fuzzyMatcher.calculateRelevancy(query, completionString);
return new LazyCompletionResult(relevancy, function() {
return {
html: renderFuzzy(query, createCompletionHtml(type, displayUrl, item.title, relevancy)),
action: action
}});
}
// Add one more layer of indirection: when filtering, we only need the string to match.
// Only after we reduced the number of possible results, we call :createLazyCompletion on them to get
// an actual completion object.
return {
completionString: completionString,
createLazyCompletion: createLazyCompletion,
}
},
stripTrailingSlash: function(url) {
if (url[url.length - 1] == "/")
url = url.substring(url, url.length - 1);
return url;
},
processResults: function(query, results) {
results = fuzzyMatcher.filter(query, results,
function(match) { return match.completionString }, this.id);
return results.map(function(result) { return result.createLazyCompletion(query); });
},
filter: function(query, callback) {
var handler = function(results) { callback(this.processResults(query, results)); }.proxy(this);
// are the results ready?
if (this.completions !== null) {
// yes: call the callback synchronously
handler(this.completions);
} else {
// no: register the handler as a callback
this.resultsReady = function(results) {
handler(results);
this.resultsReady = this.fallbackReadyCallback;
this.resultsReady(results);
}.proxy(this);
}
}
});
var FuzzyBookmarkCompleter = Class.extend({
init: function() { this.asyncCompleter = new AsyncCompleter(); },
filter: function(query, callback) { return this.asyncCompleter.filter(query, callback); },
// Traverses the bookmark hierarhcy and retuns a list of all bookmarks in the tree.
traverseBookmarkTree: function(bookmarks) {
var results = [];
var toVisit = bookmarks;
while (toVisit.length > 0) {
var bookmark = toVisit.shift();
results.push(bookmark);
if (bookmark.children)
toVisit.push.apply(toVisit, bookmark.children);
}
return results;
},
refresh: function() {
this.asyncCompleter.reset();
chrome.bookmarks.getTree(function(bookmarks) {
var results = this.traverseBookmarkTree(bookmarks);
var validResults = results.filter(function(b) { return b.url !== undefined; });
var matches = validResults.map(function(bookmark) {
return this.asyncCompleter.createUnrankedCompletion("bookmark", bookmark);
}.proxy(this));
this.asyncCompleter.resultsReady(matches);
}.proxy(this));
}
});
var FuzzyHistoryCompleter = Class.extend({
init: function(maxResults) {
this.asyncCompleter = new AsyncCompleter();
this.maxResults = maxResults;
},
filter: function(query, callback) { return this.asyncCompleter.filter(query, callback); },
refresh: function() {
this.asyncCompleter.reset();
historyCache.use(function(history) {
this.asyncCompleter.resultsReady(history.slice(-this.maxResults).map(function(item) {
return this.asyncCompleter.createUnrankedCompletion("history", item);
}.proxy(this)));
}.proxy(this));
}
});
var FuzzyTabCompleter = Class.extend({
init: function() { this.asyncCompleter = new AsyncCompleter(); },
filter: function(query, callback) { return this.asyncCompleter.filter(query, callback); },
refresh: function() {
this.asyncCompleter.reset();
chrome.tabs.getAllInWindow(null, function(tabs) {
this.asyncCompleter.resultsReady(tabs.map(function(tab) {
return this.asyncCompleter.createUnrankedCompletion("tab", tab,
{ functionName: "switchToTab", args: [tab.id] });
}.proxy(this)));
}.proxy(this));
}
});
/*
* A completer which matches only domains from sites in your history with the current query.
*/
var DomainCompleter = Class.extend({
// A mapping of doamin => useHttps, where useHttps is a boolean.
domains: null,
withDomains: function(callback) {
var self = this;
function buildResult() {
return Object.keys(self.domains).map(function(dom) {
return [dom, self.domains[dom]];
});
}
if (self.domains !== null)
return callback(buildResult());
self.domains = {};
function processDomain(domain, https) {
// non-www version is preferrable, so check if we have it already
if (domain.indexOf('www.') == 0 && self.domains.hasOwnProperty(domain.slice(4)))
domain = domain.slice(4);
// HTTPS is preferrable
https = https || self.domains[domain] || self.domains['www.' + domain];
self.domains[domain] = !!https;
delete self.domains['www.' + domain];
}
function processUrl(url) {
parts = url.split('/');
processDomain(parts[2], parts[0] == 'https:');
}
historyCache.use(function(history) {
history.forEach(function(item) { processUrl(item.url); });
});
chrome.history.onVisited.addListener(function(item) { processUrl(item.url); });
callback(buildResult());
},
refresh: function() { },
filter: function(query, callback) {
var best = null;
this.withDomains(function(domains) {
var bestOffset = 1000;
domains.forEach(function(result) {
var domain = result[0];
var protocol = result[1] ? 'https' : 'http';
var offset = domain.indexOf(query);
if (offset < 0 || offset >= bestOffset)
return;
// found a new optimum
bestOffset = offset;
var relevancy = -1.5;
best = new LazyCompletionResult(relevancy, function() {
return {
html: createCompletionHtml("site", domain, null, relevancy),
action: { functionName: "navigateToUrl", args: [protocol + "://" + domain] },
}});
});
});
callback(best ? [best] : []);
}
});
/*
* A meta-completer merges and sorts the results retrieved from a list of other completers.
*/
var MultiCompleter = Class.extend({
// Used to hide results which are not very relevant. Increase this to include more results.
maximiumRelevancyThreshold: 0.03,
/*
* - minQueryLength: the min length of a query. Anything less will return no results.
*/
init: function(sources, minQueryLength) {
if (minQueryLength === undefined)
minQueryLength = 1;
this.sources = sources;
this.minQueryLength = minQueryLength;
},
refresh: function() { this.sources.forEach(function(source) { source.refresh(); }); },
filter: function(query, maxResults, callback) {
if (query.length < this.minQueryLength) {
callback([]);
return;
}
var allResults = [];
var counter = this.sources.length;
var self = this;
this.sources.forEach(function(source) {
source.filter(query, function(results) {
allResults = allResults.concat(results);
if (--counter > 0)
return;
allResults = allResults.filter(
function(result) { return result.relevancy <= self.maximiumRelevancyThreshold });
// all sources have provided results by now, so we can sort and return
allResults.sort(function(a,b) { return a.relevancy - b.relevancy; });
// evalulate lazy completions for the top n results
callback(allResults.slice(0, maxResults).map(function(result) { return result.build(); }));
});
});
}
});
/** Singleton object that provides helpers and caching for fuzzy completion. */
var fuzzyMatcher = (function() {
var self = {};
self.timeToClean = 0;
self.cacheSize = 1000;
self.regexNonWord = /[\W_]/ig;
// cache generated regular expressions
self.regexpCache = {};
// cache filtered results from recent queries
self.filterCache = {};
self.normalizationCache = {};
/*
* Normalizes the query by stripping any non-word characters and converting to lowercase.
*/
self.normalize = function(query) {
if (!(query in self.normalizationCache))
self.normalizationCache[query] = query.replace(self.regexNonWord, "").toLowerCase();
return self.normalizationCache[query];
}
/** Returns the non-matching and matching string parts, in alternating order (starting with a
* non-matching part) or null, if the string doesn't match the query.
*
* Sample: match("codecodec","code.google.com/codec") would yield ["", "code", ".google.com/", "codec"]
*
* Note that this function matches the longest possible parts of a string and is therefore not very
* efficient. There it falls back to a more performant, but less accurate regex matching if the
* normalized query is longer than 10 characters.
*
* _Don't use this to check if a string matches a query_. Use `getRegexp(query).test(str)` instead.
*/
self.getMatchGroups = function(query, str) {
query = self.normalize(query);
if (query.length == 0)
return str.length ? [str] : [];
if (query.length > 15) {
// for long query strings, the method is much too inefficient, so fall
// back to the less accurate regex matching
return self.getRegexp(query).exec(str).slice(1);
}
for (var i = query.length; i >= 1; --i) {
var part = query.slice(0, i);
var partOffset = str.toLowerCase().indexOf(part);
if (partOffset < 0)
continue;
// we use recursive backtracking here, this is why it's slow.
rest = self.getMatchGroups(query.slice(i), str.slice(partOffset + i));
if (!rest) continue;
return [
str.slice(0, partOffset),
part,
].concat(rest);
}
return null;
}
/** Calculates a very simple similarity value between a :query and a :string */
self.calculateRelevancy = function(query, str) {
query = self.normalize(query);
str = self.normalize(str);
var sum = 0;
// Ignore any matches between the query and the str which are 2 characters are less.
var minimumCharacterMatch = 3;
// only iterate over slices of the query starting at an offset up to 20 to save resources
for (var start = 0; start < 20 && start < query.length; ++start) {
for (var i = query.length; i >= start + (minimumCharacterMatch - 1); --i) {
if (str.indexOf(query.slice(start, i)) >= 0) {
var length = i - start;
sum += length * length;
break;
}
}
}
return sum * sum * sum;
}
/** Trims the size of the caches to the configured size using a FIFO algorithm. */
self.cleanCache = function() {
// remove old cached regexes
Object.keys(self.regexpCache).slice(self.cacheSize).forEach(function(query) {
delete self.regexpCache[query];
});
// remove old cached normalization results
Object.keys(self.normalizationCache).slice(self.cacheSize).forEach(function(query) {
delete self.normalizationCache[query];
});
}
/** Returns a regex that matches a string using a fuzzy :query. Example: The :query "abc" would result
* in a regex like /^([^a])*(a)([^b])*(b)([^c])*(c)(.*)$/
*/
self.getRegexp = function(query) {
query = self.normalize(query);
if (!(query in self.regexpCache)) {
// build up a regex for fuzzy matching. This is the fastest method I checked (faster than:
// string building, splice, concat, multi-level join)
var regex = ['^'];
for (var i = 0; i < query.length; ++i) {
regex.push('([^');
regex.push(query[i]);
regex.push(']*)(');
regex.push(query[i]);
regex.push(')');
}
regex.push('(.*)$');
self.regexpCache[query] = new RegExp(regex.join(''), 'i');
}
return self.regexpCache[query];
}
/** Clear the cache for the given source, e.g. for refreshing */
self.invalidateFilterCache = function(id) {
self.filterCache[id] = {};
}
/** Filters a collection :source using fuzzy matching against an input string :query. If a query with
* a less specific query was issued before (e.g. if the user added a letter to the query), the cached
* results of the last filtering are used as a starting point, instead of :source.
*/
self.filter = function(query, source, getValue, id) {
if (!(id in self.filterCache))
self.filterCache[id] = {};
// find the most narrow list of results in the cache
var optSpecificity = source.length;
var specificity;
for (key in self.filterCache[id]) {
if (!self.filterCache[id].hasOwnProperty(key))
continue;
if ((query.indexOf(key) != 0 && key.indexOf(query) != 0) || key.length > query.length) {
// cache entry no longer needed
delete self.filterCache[id][key];
continue;
}
// is this a plausible result set to use as a source?
if (query.indexOf(key) < 0)
continue;
// is this cache entry the most specific so far?
specificity = self.filterCache[id][key].length;
if (specificity < optSpecificity) {
source = self.filterCache[id][key];
optSpecificity = specificity;
}
}
// don't clean up the caches every iteration
if (++self.timeToClean > 100) {
self.timeToClean = 0;
self.cleanCache();
}
var regexp = self.getRegexp(query);
var filtered = source.filter(function(x) { return regexp.test(getValue(x)) });
self.filterCache[id][query] = filtered;
return filtered;
}
return self;
})();
var htmlRegex = /<[^>]*>|&[a-z]+;/gi;
/** Strips HTML tags and entities using a naive regex replacement. Optionally, saves the stripped
* HTML tags in a dictionary indexed by the position where the tag should be reinserted. */
function stripHtmlTags(str, positions) {
if (!positions)
return str.replace(htmlRegex, '');
var match = str.match(htmlRegex);
if (!match) return;
match.reverse();
var split = str.split(htmlRegex);
var offset = 0;
var i = 0;
split.forEach(function(text) {
if (match.length > 0)
positions[offset += text.length] = match.pop();
});
return split.join('');
}
/*
* Creates the HTML used to display this completion.
* :title and :relevancy are optional.
*/
function createCompletionHtml(type, url, title, relevancy) {
title = title || "";
var html = '<span class="source">' + type + '</span> ' + utils.escapeHtml(url);
if (title.length > 0)
html += '<span class="title">' + utils.escapeHtml(title) + '</span>';
if (showRelevancyScoreInResults)
html += '<span class="relevancy">' + (Math.floor(relevancy * 100000) / 100000.0) + '</span>';
return html;
}
/** Renders a completion by marking fuzzy-matched parts. */
function renderFuzzy(query, html) {
// we want to match the content in HTML tags, but not the HTML tags themselves, so we remove the
// tags and reinsert them after the matching process
var htmlTags = {};
var groups = fuzzyMatcher.getMatchGroups(query, stripHtmlTags(html, htmlTags));
html = [];
var htmlOffset = 0;
// this helper function adds the HTML generated _for one single character_ to the HTML output
// and reinserts HTML tags stripped before, if they were at this position
function addToHtml(str) {
if (htmlOffset in htmlTags)
html.push(htmlTags[htmlOffset]);
html.push(str);
++htmlOffset;
}
function addCharsWithDecoration(str, before, after) {
before = before || '';
after = after || '';
for (var i = 0; i < str.length; ++i)
addToHtml(before + str[i] + after);
}
// Don't render matches between the query and the str which are 2 characters are less.
var minimumCharacterMatch = 3;
// iterate over the match groups. They are non-matched and matched string parts, in alternating order
for (var i = 0; i < groups.length; ++i) {
if (i % 2 == 0 || groups[i].length < minimumCharacterMatch)
// we have a non-matched part, it could have several characters. We need to insert them character
// by character, so that addToHtml can keep track of the position in the original string
addCharsWithDecoration(groups[i]);
else
// we have a matched part. In addition to the characters themselves, we add some decorating HTML.
addCharsWithDecoration(groups[i], '<span class="fuzzyMatch">', '</span>');
};
// call it another time so that a tag at the very last position is reinserted
addToHtml('');
return html.join('');
}
/** Singleton object that provides fast access to the Chrome history */
var historyCache = (function() {
var size = 20000;
var cachedHistory = null;
function use(callback) {
if (cachedHistory !== null)
return callback(cachedHistory);
chrome.history.search({ text: '', maxResults: size, startTime: 0 }, function(history) {
// sorting in ascending order, so we can push new items to the end later
history.sort(function(a, b) {
return (a.lastVisitTime|| 0) - (b.lastVisitTime || 0);
});
cachedHistory = history;
callback(history);
});
chrome.history.onVisited.addListener(function(item) {
// only cache newly visited sites
if (item.visitCount === 1)
cachedHistory.push(item);
});
}
return { use: use };
})();
// public interface
return {
FuzzyBookmarkCompleter: FuzzyBookmarkCompleter,
FuzzyHistoryCompleter: FuzzyHistoryCompleter,
FuzzyTabCompleter: FuzzyTabCompleter,
SmartKeywordCompleter: SmartKeywordCompleter,
DomainCompleter: DomainCompleter,
MultiCompleter: MultiCompleter
};
})()