📝 Added entire config directory for backup purposes
|
|
@ -0,0 +1,166 @@
|
|||
const TIME_DELTA = 500; // Milliseconds.
|
||||
|
||||
// TabRecency associates a logical timestamp with each tab id. These are used to provide an initial
|
||||
// recency-based ordering in the tabs vomnibar (which allows jumping quickly between recently-visited tabs).
|
||||
class TabRecency {
|
||||
|
||||
constructor() {
|
||||
this.timestamp = 1;
|
||||
this.current = -1;
|
||||
this.cache = {};
|
||||
this.lastVisited = null;
|
||||
this.lastVisitedTime = null;
|
||||
|
||||
chrome.tabs.onActivated.addListener(activeInfo => this.register(activeInfo.tabId));
|
||||
chrome.tabs.onRemoved.addListener(tabId => this.deregister(tabId));
|
||||
|
||||
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
|
||||
this.deregister(removedTabId);
|
||||
this.register(addedTabId);
|
||||
});
|
||||
|
||||
if (chrome.windows != null) {
|
||||
chrome.windows.onFocusChanged.addListener(wnd => {
|
||||
if (wnd !== chrome.windows.WINDOW_ID_NONE) {
|
||||
chrome.tabs.query({windowId: wnd, active: true}, tabs => {
|
||||
if (tabs[0])
|
||||
this.register(tabs[0].id);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
register(tabId) {
|
||||
const currentTime = new Date();
|
||||
// Register tabId if it has been visited for at least @timeDelta ms. Tabs which are visited only for a
|
||||
// very-short time (e.g. those passed through with `5J`) aren't registered as visited at all.
|
||||
if ((this.lastVisitedTime != null) && (TIME_DELTA <= (currentTime - this.lastVisitedTime))) {
|
||||
this.cache[this.lastVisited] = ++this.timestamp;
|
||||
}
|
||||
|
||||
this.current = (this.lastVisited = tabId);
|
||||
this.lastVisitedTime = currentTime;
|
||||
}
|
||||
|
||||
deregister(tabId) {
|
||||
if (tabId === this.lastVisited) {
|
||||
// Ensure we don't register this tab, since it's going away.
|
||||
this.lastVisited = (this.lastVisitedTime = null);
|
||||
}
|
||||
delete this.cache[tabId];
|
||||
}
|
||||
|
||||
// Recently-visited tabs get a higher score (except the current tab, which gets a low score).
|
||||
recencyScore(tabId) {
|
||||
if (!this.cache[tabId])
|
||||
this.cache[tabId] = 1;
|
||||
if (tabId === this.current)
|
||||
return 0.0;
|
||||
else
|
||||
return this.cache[tabId] / this.timestamp;
|
||||
}
|
||||
|
||||
// Returns a list of tab Ids sorted by recency, most recent tab first.
|
||||
getTabsByRecency() {
|
||||
const tabIds = Object.keys(this.cache || {});
|
||||
tabIds.sort((a,b) => this.cache[b] - this.cache[a]);
|
||||
return tabIds.map(tId => parseInt(tId));
|
||||
}
|
||||
}
|
||||
|
||||
var BgUtils = {
|
||||
tabRecency: new TabRecency(),
|
||||
|
||||
// Log messages to the extension's logging page, but only if that page is open.
|
||||
log: (function() {
|
||||
const loggingPageUrl = chrome.runtime.getURL("pages/logging.html");
|
||||
if (loggingPageUrl != null) { console.log(`Vimium logging URL:\n ${loggingPageUrl}`); } // Do not output URL for tests.
|
||||
// For development, it's sometimes useful to automatically launch the logging page on reload.
|
||||
if (localStorage.autoLaunchLoggingPage) { chrome.windows.create({url: loggingPageUrl, focused: false}); }
|
||||
return function(message, sender = null) {
|
||||
for (let viewWindow of chrome.extension.getViews({type: "tab"})) {
|
||||
if (viewWindow.location.pathname === "/pages/logging.html") {
|
||||
// Don't log messages from the logging page itself. We do this check late because most of the time
|
||||
// it's not needed.
|
||||
if ((sender != null ? sender.url : undefined) !== loggingPageUrl) {
|
||||
const date = new Date;
|
||||
let [hours, minutes, seconds, milliseconds] =
|
||||
[date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()];
|
||||
if (minutes < 10) { minutes = "0" + minutes; }
|
||||
if (seconds < 10) { seconds = "0" + seconds; }
|
||||
if (milliseconds < 10) { milliseconds = "00" + milliseconds; }
|
||||
if (milliseconds < 100) { milliseconds = "0" + milliseconds; }
|
||||
const dateString = `${hours}:${minutes}:${seconds}.${milliseconds}`;
|
||||
const logElement = viewWindow.document.getElementById("log-text");
|
||||
logElement.value += `${dateString}: ${message}\n`;
|
||||
logElement.scrollTop = 2000000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
})(),
|
||||
|
||||
// Remove comments and leading/trailing whitespace from a list of lines, and merge lines where the last
|
||||
// character on the preceding line is "\".
|
||||
parseLines(text) {
|
||||
return text.replace(/\\\n/g, "")
|
||||
.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(line => (line.length > 0) && !(Array.from('#"').includes(line[0])));
|
||||
},
|
||||
|
||||
escapedEntities: {
|
||||
'"': ""s;",
|
||||
'&': "&",
|
||||
"'": "'",
|
||||
"<": "<",
|
||||
">": ">"
|
||||
},
|
||||
|
||||
escapeAttribute(string) {
|
||||
return string.replace(/["&'<>]/g, char => BgUtils.escapedEntities[char]);
|
||||
}
|
||||
};
|
||||
|
||||
// Utility for parsing and using the custom search-engine configuration. We re-use the previous parse if the
|
||||
// search-engine configuration is unchanged.
|
||||
const SearchEngines = {
|
||||
previousSearchEngines: null,
|
||||
searchEngines: null,
|
||||
|
||||
refresh(searchEngines) {
|
||||
if ((this.previousSearchEngines == null) || (searchEngines !== this.previousSearchEngines)) {
|
||||
this.previousSearchEngines = searchEngines;
|
||||
this.searchEngines = new AsyncDataFetcher(function(callback) {
|
||||
const engines = {};
|
||||
for (let line of BgUtils.parseLines(searchEngines)) {
|
||||
const tokens = line.split(/\s+/);
|
||||
if (2 <= tokens.length) {
|
||||
const keyword = tokens[0].split(":")[0];
|
||||
const searchUrl = tokens[1];
|
||||
const description = tokens.slice(2).join(" ") || `search (${keyword})`;
|
||||
if (Utils.hasFullUrlPrefix(searchUrl) || Utils.hasJavascriptPrefix(searchUrl))
|
||||
engines[keyword] = {keyword, searchUrl, description};
|
||||
}
|
||||
}
|
||||
|
||||
callback(engines);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Use the parsed search-engine configuration, possibly asynchronously.
|
||||
use(callback) { this.searchEngines.use(callback); },
|
||||
|
||||
// Both set (refresh) the search-engine configuration and use it at the same time.
|
||||
refreshAndUse(searchEngines, callback) {
|
||||
this.refresh(searchEngines);
|
||||
this.use(callback);
|
||||
}
|
||||
};
|
||||
|
||||
BgUtils.TIME_DELTA = TIME_DELTA; // Referenced by our tests.
|
||||
|
||||
window.SearchEngines = SearchEngines;
|
||||
window.BgUtils = BgUtils;
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
const Commands = {
|
||||
availableCommands: {},
|
||||
keyToCommandRegistry: null,
|
||||
mapKeyRegistry: null,
|
||||
|
||||
init() {
|
||||
for (let command of Object.keys(commandDescriptions)) {
|
||||
const [description, options] = commandDescriptions[command];
|
||||
this.availableCommands[command] = Object.assign((options || {}), {description});
|
||||
}
|
||||
|
||||
Settings.postUpdateHooks["keyMappings"] = this.loadKeyMappings.bind(this);
|
||||
this.loadKeyMappings(Settings.get("keyMappings"));
|
||||
},
|
||||
|
||||
loadKeyMappings(customKeyMappings) {
|
||||
let key, command;
|
||||
this.keyToCommandRegistry = {};
|
||||
this.mapKeyRegistry = {};
|
||||
|
||||
const configLines = Object.keys(defaultKeyMappings).map((key) => `map ${key} ${defaultKeyMappings[key]}`);
|
||||
configLines.push(...BgUtils.parseLines(customKeyMappings));
|
||||
|
||||
const seen = {};
|
||||
let unmapAll = false;
|
||||
for (let line of configLines.reverse()) {
|
||||
const tokens = line.split(/\s+/);
|
||||
switch (tokens[0].toLowerCase()) {
|
||||
case "map":
|
||||
if ((3 <= tokens.length) && !unmapAll) {
|
||||
var _, optionList, registryEntry;
|
||||
[_, key, command, ...optionList] = tokens;
|
||||
if (!seen[key] && (registryEntry = this.availableCommands[command])) {
|
||||
seen[key] = true;
|
||||
const keySequence = this.parseKeySequence(key);
|
||||
const options = this.parseCommandOptions(command, optionList);
|
||||
this.keyToCommandRegistry[key] =
|
||||
Object.assign({keySequence, command, options, optionList}, this.availableCommands[command]);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "unmap":
|
||||
if (tokens.length == 2)
|
||||
seen[tokens[1]] = true;
|
||||
break;
|
||||
case "unmapall":
|
||||
unmapAll = true;
|
||||
break;
|
||||
case "mapkey":
|
||||
if (tokens.length === 3) {
|
||||
const fromChar = this.parseKeySequence(tokens[1]);
|
||||
const toChar = this.parseKeySequence(tokens[2]);
|
||||
if ((fromChar.length === toChar.length && toChar.length === 1)
|
||||
&& this.mapKeyRegistry[fromChar[0]] == null) {
|
||||
this.mapKeyRegistry[fromChar[0]] = toChar[0];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
chrome.storage.local.set({mapKeyRegistry: this.mapKeyRegistry});
|
||||
this.installKeyStateMapping();
|
||||
this.prepareHelpPageData();
|
||||
|
||||
// Push the key mapping for passNextKey into Settings so that it's available in the front end for insert
|
||||
// mode. We exclude single-key mappings (that is, printable keys) because when users press printable keys
|
||||
// in insert mode they expect the character to be input, not to be droppped into some special Vimium
|
||||
// mode.
|
||||
const passNextKeys = Object.keys(this.keyToCommandRegistry)
|
||||
.filter(key => (this.keyToCommandRegistry[key].command === "passNextKey") && (key.length > 1));
|
||||
Settings.set("passNextKeyKeys", passNextKeys);
|
||||
},
|
||||
|
||||
// Lower-case the appropriate portions of named keys.
|
||||
//
|
||||
// A key name is one of three forms exemplified by <c-a> <left> or <c-f12>
|
||||
// (prefixed normal key, named key, or prefixed named key). Internally, for
|
||||
// simplicity, we would like prefixes and key names to be lowercase, though
|
||||
// humans may prefer other forms <Left> or <C-a>.
|
||||
// On the other hand, <c-a> and <c-A> are different named keys - for one of
|
||||
// them you have to press "shift" as well.
|
||||
// We sort modifiers here to match the order used in keyboard_utils.js.
|
||||
// The return value is a sequence of keys: e.g. "<Space><c-A>b" -> ["<space>", "<c-A>", "b"].
|
||||
parseKeySequence: (function() {
|
||||
const modifier = "(?:[acms]-)"; // E.g. "a-", "c-", "m-", "s-".
|
||||
const namedKey = "(?:[a-z][a-z0-9]+)"; // E.g. "left" or "f12" (always two characters or more).
|
||||
const modifiedKey = `(?:${modifier}+(?:.|${namedKey}))`; // E.g. "c-*" or "c-left".
|
||||
const specialKeyRegexp = new RegExp(`^<(${namedKey}|${modifiedKey})>(.*)`, "i");
|
||||
return function(key) {
|
||||
if (key.length === 0) {
|
||||
return [];
|
||||
// Parse "<c-a>bcd" as "<c-a>" and "bcd".
|
||||
} else if (0 === key.search(specialKeyRegexp)) {
|
||||
const array = RegExp.$1.split("-");
|
||||
const adjustedLength = Math.max(array.length, 1)
|
||||
let modifiers = array.slice(0, adjustedLength - 1);
|
||||
let keyChar = array[adjustedLength - 1];
|
||||
if (keyChar.length !== 1)
|
||||
keyChar = keyChar.toLowerCase();
|
||||
modifiers = modifiers.map(m => m.toLowerCase());
|
||||
modifiers.sort();
|
||||
return ["<" + modifiers.concat([keyChar]).join("-") + ">", ...this.parseKeySequence(RegExp.$2)];
|
||||
} else {
|
||||
return [key[0], ...this.parseKeySequence(key.slice(1))];
|
||||
}
|
||||
};
|
||||
})(),
|
||||
|
||||
// Command options follow command mappings, and are of one of two forms:
|
||||
// key=value - a value
|
||||
// key - a flag
|
||||
parseCommandOptions(command, optionList) {
|
||||
const options = {};
|
||||
for (let option of Array.from(optionList)) {
|
||||
const parse = option.split("=", 2);
|
||||
options[parse[0]] = parse.length === 1 ? true : parse[1];
|
||||
}
|
||||
|
||||
// We parse any `count` option immediately (to avoid having to parse it repeatedly later).
|
||||
if ("count" in options) {
|
||||
options.count = parseInt(options.count);
|
||||
if (isNaN(options.count) || this.availableCommands[command].noRepeat)
|
||||
delete options.count;
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
|
||||
// This generates and installs a nested key-to-command mapping structure. There is an example in
|
||||
// mode_key_handler.js.
|
||||
installKeyStateMapping() {
|
||||
const keyStateMapping = {};
|
||||
for (let keys of Object.keys(this.keyToCommandRegistry || {})) {
|
||||
const registryEntry = this.keyToCommandRegistry[keys];
|
||||
let currentMapping = keyStateMapping;
|
||||
for (let index = 0; index < registryEntry.keySequence.length; index++) {
|
||||
const key = registryEntry.keySequence[index];
|
||||
if (currentMapping[key] != null ? currentMapping[key].command : undefined) {
|
||||
// Do not overwrite existing command bindings, they take priority. NOTE(smblott) This is the legacy
|
||||
// behaviour.
|
||||
break;
|
||||
} else if (index < (registryEntry.keySequence.length - 1)) {
|
||||
currentMapping = currentMapping[key] != null ? currentMapping[key] : (currentMapping[key] = {});
|
||||
} else {
|
||||
currentMapping[key] = Object.assign({}, registryEntry);
|
||||
// We don't need these properties in the content scripts.
|
||||
for (let prop of ["keySequence", "description"])
|
||||
delete currentMapping[key][prop];
|
||||
}
|
||||
}
|
||||
}
|
||||
chrome.storage.local.set({normalModeKeyStateMapping: keyStateMapping});
|
||||
// Inform `KeyboardUtils.isEscape()` whether `<c-[>` should be interpreted as `Escape` (which it is by
|
||||
// default).
|
||||
chrome.storage.local.set({useVimLikeEscape: !("<c-[>" in keyStateMapping)});
|
||||
},
|
||||
|
||||
// Build the "helpPageData" data structure which the help page needs and place it in Chrome storage.
|
||||
prepareHelpPageData() {
|
||||
const commandToKey = {};
|
||||
for (let key of Object.keys(this.keyToCommandRegistry || {})) {
|
||||
const registryEntry = this.keyToCommandRegistry[key];
|
||||
(commandToKey[registryEntry.command] != null ? commandToKey[registryEntry.command] : (commandToKey[registryEntry.command] = [])).push(key);
|
||||
}
|
||||
const commandGroups = {};
|
||||
for (let group of Object.keys(this.commandGroups || {})) {
|
||||
const commands = this.commandGroups[group];
|
||||
commandGroups[group] = [];
|
||||
for (let command of commands) {
|
||||
commandGroups[group].push({
|
||||
command,
|
||||
description: this.availableCommands[command].description,
|
||||
keys: commandToKey[command] != null ? commandToKey[command] : [],
|
||||
advanced: this.advancedCommands.includes(command)
|
||||
});
|
||||
}
|
||||
}
|
||||
chrome.storage.local.set({helpPageData: commandGroups});
|
||||
},
|
||||
|
||||
// An ordered listing of all available commands, grouped by type. This is the order they will
|
||||
// be shown in the help page.
|
||||
commandGroups: {
|
||||
pageNavigation:
|
||||
["scrollDown",
|
||||
"scrollUp",
|
||||
"scrollToTop",
|
||||
"scrollToBottom",
|
||||
"scrollPageDown",
|
||||
"scrollPageUp",
|
||||
"scrollFullPageDown",
|
||||
"scrollFullPageUp",
|
||||
"scrollLeft",
|
||||
"scrollRight",
|
||||
"scrollToLeft",
|
||||
"scrollToRight",
|
||||
"reload",
|
||||
"copyCurrentUrl",
|
||||
"openCopiedUrlInCurrentTab",
|
||||
"openCopiedUrlInNewTab",
|
||||
"goUp",
|
||||
"goToRoot",
|
||||
"enterInsertMode",
|
||||
"enterVisualMode",
|
||||
"enterVisualLineMode",
|
||||
"passNextKey",
|
||||
"focusInput",
|
||||
"LinkHints.activateMode",
|
||||
"LinkHints.activateModeToOpenInNewTab",
|
||||
"LinkHints.activateModeToOpenInNewForegroundTab",
|
||||
"LinkHints.activateModeWithQueue",
|
||||
"LinkHints.activateModeToDownloadLink",
|
||||
"LinkHints.activateModeToOpenIncognito",
|
||||
"LinkHints.activateModeToCopyLinkUrl",
|
||||
"goPrevious",
|
||||
"goNext",
|
||||
"nextFrame",
|
||||
"mainFrame",
|
||||
"Marks.activateCreateMode",
|
||||
"Marks.activateGotoMode"],
|
||||
vomnibarCommands:
|
||||
["Vomnibar.activate",
|
||||
"Vomnibar.activateInNewTab",
|
||||
"Vomnibar.activateBookmarks",
|
||||
"Vomnibar.activateBookmarksInNewTab",
|
||||
"Vomnibar.activateTabSelection",
|
||||
"Vomnibar.activateEditUrl",
|
||||
"Vomnibar.activateEditUrlInNewTab"],
|
||||
findCommands: ["enterFindMode", "performFind", "performBackwardsFind"],
|
||||
historyNavigation:
|
||||
["goBack", "goForward"],
|
||||
tabManipulation:
|
||||
["createTab",
|
||||
"previousTab",
|
||||
"nextTab",
|
||||
"visitPreviousTab",
|
||||
"firstTab",
|
||||
"lastTab",
|
||||
"duplicateTab",
|
||||
"togglePinTab",
|
||||
"toggleMuteTab",
|
||||
"removeTab",
|
||||
"restoreTab",
|
||||
"moveTabToNewWindow",
|
||||
"closeTabsOnLeft","closeTabsOnRight",
|
||||
"closeOtherTabs",
|
||||
"moveTabLeft",
|
||||
"moveTabRight"],
|
||||
misc:
|
||||
["showHelp",
|
||||
"toggleViewSource"]
|
||||
},
|
||||
|
||||
// Rarely used commands are not shown by default in the help dialog or in the README. The goal is to present
|
||||
// a focused, high-signal set of commands to the new and casual user. Only those truly hungry for more power
|
||||
// from Vimium will uncover these gems.
|
||||
advancedCommands: [
|
||||
"scrollToLeft",
|
||||
"scrollToRight",
|
||||
"moveTabToNewWindow",
|
||||
"goUp",
|
||||
"goToRoot",
|
||||
"LinkHints.activateModeWithQueue",
|
||||
"LinkHints.activateModeToDownloadLink",
|
||||
"Vomnibar.activateEditUrl",
|
||||
"Vomnibar.activateEditUrlInNewTab",
|
||||
"LinkHints.activateModeToOpenIncognito",
|
||||
"LinkHints.activateModeToCopyLinkUrl",
|
||||
"goNext",
|
||||
"goPrevious",
|
||||
"Marks.activateCreateMode",
|
||||
"Marks.activateGotoMode",
|
||||
"moveTabLeft",
|
||||
"moveTabRight",
|
||||
"closeTabsOnLeft",
|
||||
"closeTabsOnRight",
|
||||
"closeOtherTabs",
|
||||
"enterVisualLineMode",
|
||||
"toggleViewSource",
|
||||
"passNextKey"]
|
||||
};
|
||||
|
||||
const defaultKeyMappings = {
|
||||
// Navigating the current page
|
||||
"j": "scrollDown",
|
||||
"k": "scrollUp",
|
||||
"h": "scrollLeft",
|
||||
"l": "scrollRight",
|
||||
"gg": "scrollToTop",
|
||||
"G": "scrollToBottom",
|
||||
"zH": "scrollToLeft",
|
||||
"zL": "scrollToRight",
|
||||
"<c-e>": "scrollDown",
|
||||
"<c-y>": "scrollUp",
|
||||
"d": "scrollPageDown",
|
||||
"u": "scrollPageUp",
|
||||
"r": "reload",
|
||||
"yy": "copyCurrentUrl",
|
||||
"p": "openCopiedUrlInCurrentTab",
|
||||
"P": "openCopiedUrlInNewTab",
|
||||
"gi": "focusInput",
|
||||
"[[": "goPrevious",
|
||||
"]]": "goNext",
|
||||
"gf": "nextFrame",
|
||||
"gF": "mainFrame",
|
||||
"gu": "goUp",
|
||||
"gU": "goToRoot",
|
||||
"i": "enterInsertMode",
|
||||
"v": "enterVisualMode",
|
||||
"V": "enterVisualLineMode",
|
||||
|
||||
// Link hints
|
||||
"f": "LinkHints.activateMode",
|
||||
"F": "LinkHints.activateModeToOpenInNewTab",
|
||||
"<a-f>": "LinkHints.activateModeWithQueue",
|
||||
"yf": "LinkHints.activateModeToCopyLinkUrl",
|
||||
|
||||
// Using find
|
||||
"/": "enterFindMode",
|
||||
"n": "performFind",
|
||||
"N": "performBackwardsFind",
|
||||
|
||||
// Vomnibar
|
||||
"o": "Vomnibar.activate",
|
||||
"O": "Vomnibar.activateInNewTab",
|
||||
"T": "Vomnibar.activateTabSelection",
|
||||
"b": "Vomnibar.activateBookmarks",
|
||||
"B": "Vomnibar.activateBookmarksInNewTab",
|
||||
"ge": "Vomnibar.activateEditUrl",
|
||||
"gE": "Vomnibar.activateEditUrlInNewTab",
|
||||
|
||||
// Navigating history
|
||||
"H": "goBack",
|
||||
"L": "goForward",
|
||||
|
||||
// Manipulating tabs
|
||||
"K": "nextTab",
|
||||
"J": "previousTab",
|
||||
"gt": "nextTab",
|
||||
"gT": "previousTab",
|
||||
"^": "visitPreviousTab",
|
||||
"<<": "moveTabLeft",
|
||||
">>": "moveTabRight",
|
||||
"g0": "firstTab",
|
||||
"g$": "lastTab",
|
||||
"W": "moveTabToNewWindow",
|
||||
"t": "createTab",
|
||||
"yt": "duplicateTab",
|
||||
"x": "removeTab",
|
||||
"X": "restoreTab",
|
||||
"<a-p>": "togglePinTab",
|
||||
"<a-m>": "toggleMuteTab",
|
||||
|
||||
// Marks
|
||||
"m": "Marks.activateCreateMode",
|
||||
"`": "Marks.activateGotoMode",
|
||||
|
||||
// Misc
|
||||
"?": "showHelp",
|
||||
"gs": "toggleViewSource"
|
||||
};
|
||||
|
||||
|
||||
// This is a mapping of: commandIdentifier => [description, options].
|
||||
// If the noRepeat and repeatLimit options are both specified, then noRepeat takes precedence.
|
||||
const commandDescriptions = {
|
||||
// Navigating the current page
|
||||
showHelp: ["Show help", { topFrame: true, noRepeat: true }],
|
||||
scrollDown: ["Scroll down"],
|
||||
scrollUp: ["Scroll up"],
|
||||
scrollLeft: ["Scroll left"],
|
||||
scrollRight: ["Scroll right"],
|
||||
|
||||
scrollToTop: ["Scroll to the top of the page"],
|
||||
scrollToBottom: ["Scroll to the bottom of the page", { noRepeat: true }],
|
||||
scrollToLeft: ["Scroll all the way to the left", { noRepeat: true }],
|
||||
scrollToRight: ["Scroll all the way to the right", { noRepeat: true }],
|
||||
|
||||
scrollPageDown: ["Scroll a half page down"],
|
||||
scrollPageUp: ["Scroll a half page up"],
|
||||
scrollFullPageDown: ["Scroll a full page down"],
|
||||
scrollFullPageUp: ["Scroll a full page up"],
|
||||
|
||||
reload: ["Reload the page", { background: true }],
|
||||
toggleViewSource: ["View page source", { noRepeat: true }],
|
||||
|
||||
copyCurrentUrl: ["Copy the current URL to the clipboard", { noRepeat: true }],
|
||||
openCopiedUrlInCurrentTab: ["Open the clipboard's URL in the current tab", { noRepeat: true }],
|
||||
openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { repeatLimit: 20 }],
|
||||
|
||||
enterInsertMode: ["Enter insert mode", { noRepeat: true }],
|
||||
passNextKey: ["Pass the next key to the page"],
|
||||
enterVisualMode: ["Enter visual mode", { noRepeat: true }],
|
||||
enterVisualLineMode: ["Enter visual line mode", { noRepeat: true }],
|
||||
|
||||
focusInput: ["Focus the first text input on the page"],
|
||||
|
||||
"LinkHints.activateMode": ["Open a link in the current tab"],
|
||||
"LinkHints.activateModeToOpenInNewTab": ["Open a link in a new tab"],
|
||||
"LinkHints.activateModeToOpenInNewForegroundTab": ["Open a link in a new tab & switch to it"],
|
||||
"LinkHints.activateModeWithQueue": ["Open multiple links in a new tab", { noRepeat: true }],
|
||||
"LinkHints.activateModeToOpenIncognito": ["Open a link in incognito window"],
|
||||
"LinkHints.activateModeToDownloadLink": ["Download link url"],
|
||||
"LinkHints.activateModeToCopyLinkUrl": ["Copy a link URL to the clipboard"],
|
||||
|
||||
enterFindMode: ["Enter find mode", { noRepeat: true }],
|
||||
performFind: ["Cycle forward to the next find match"],
|
||||
performBackwardsFind: ["Cycle backward to the previous find match"],
|
||||
|
||||
goPrevious: ["Follow the link labeled previous or <", { noRepeat: true }],
|
||||
goNext: ["Follow the link labeled next or >", { noRepeat: true }],
|
||||
|
||||
// Navigating your history
|
||||
goBack: ["Go back in history"],
|
||||
goForward: ["Go forward in history"],
|
||||
|
||||
// Navigating the URL hierarchy
|
||||
goUp: ["Go up the URL hierarchy"],
|
||||
goToRoot: ["Go to root of current URL hierarchy"],
|
||||
|
||||
// Manipulating tabs
|
||||
nextTab: ["Go one tab right", { background: true }],
|
||||
previousTab: ["Go one tab left", { background: true }],
|
||||
visitPreviousTab: ["Go to previously-visited tab", { background: true }],
|
||||
firstTab: ["Go to the first tab", { background: true }],
|
||||
lastTab: ["Go to the last tab", { background: true }],
|
||||
|
||||
createTab: ["Create new tab", { background: true, repeatLimit: 20 }],
|
||||
duplicateTab: ["Duplicate current tab", { background: true, repeatLimit: 20 }],
|
||||
removeTab: ["Close current tab", { background: true,
|
||||
repeatLimit: (chrome.sessions ? chrome.sessions.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();
|
||||
|
||||
window.Commands = Commands;
|
||||
|
|
@ -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|smile)\\.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
|
||||
];
|
||||
|
||||
window.CompletionEngines = CompletionEngines;
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
// This is a wrapper class for completion engines. It handles the case where a custom search engine includes a
|
||||
// prefix query term (or terms). For example:
|
||||
//
|
||||
// https://www.google.com/search?q=javascript+%s
|
||||
//
|
||||
// In this case, we get better suggestions if we include the term "javascript" in queries sent to the
|
||||
// completion engine. This wrapper handles adding such prefixes to completion-engine queries and removing them
|
||||
// from the resulting suggestions.
|
||||
class EnginePrefixWrapper {
|
||||
constructor(searchUrl, engine) {
|
||||
this.searchUrl = searchUrl;
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
getUrl(queryTerms) {
|
||||
// This tests whether @searchUrl contains something of the form "...=abc+def+%s...", from which we extract
|
||||
// a prefix of the form "abc def ".
|
||||
if (/\=.+\+%s/.test(this.searchUrl)) {
|
||||
let terms = this.searchUrl.replace(/\+%s.*/, "");
|
||||
terms = terms.replace(/.*=/, "");
|
||||
terms = terms.replace(/\+/g, " ");
|
||||
|
||||
queryTerms = [ ...terms.split(" "), ...queryTerms ];
|
||||
const prefix = `${terms} `;
|
||||
|
||||
this.postprocessSuggestions = (suggestions) => {
|
||||
return suggestions
|
||||
.filter(s => s.startsWith(prefix))
|
||||
.map(s => s.slice(prefix.length));
|
||||
};
|
||||
}
|
||||
|
||||
return this.engine.getUrl(queryTerms);
|
||||
}
|
||||
|
||||
parse(xhr) {
|
||||
return this.postprocessSuggestions(this.engine.parse(xhr));
|
||||
}
|
||||
|
||||
postprocessSuggestions(suggestions) { return suggestions; }
|
||||
}
|
||||
|
||||
const CompletionSearch = {
|
||||
debug: false,
|
||||
inTransit: {},
|
||||
completionCache: new SimpleCache(2 * 60 * 60 * 1000, 5000), // Two hours, 5000 entries.
|
||||
engineCache:new SimpleCache(1000 * 60 * 60 * 1000), // 1000 hours.
|
||||
|
||||
// The amount of time to wait for new requests before launching the current request (for example, if the user
|
||||
// is still typing).
|
||||
delay: 100,
|
||||
|
||||
get(searchUrl, url, callback) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", url, true);
|
||||
xhr.timeout = 2500;
|
||||
// According to https://xhr.spec.whatwg.org/#request-error-steps,
|
||||
// readystatechange always gets called whether a request succeeds or not,
|
||||
// and the `readyState == 4` means an associated `state` is "done", which is true even if any error happens
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4)
|
||||
return callback(xhr.status === 200 ? xhr : null);
|
||||
};
|
||||
return xhr.send();
|
||||
},
|
||||
|
||||
// Look up the completion engine for this searchUrl. Because of DummyCompletionEngine, we know there will
|
||||
// always be a match.
|
||||
lookupEngine(searchUrl) {
|
||||
if (this.engineCache.has(searchUrl)) {
|
||||
return this.engineCache.get(searchUrl);
|
||||
} else {
|
||||
for (let engine of Array.from(CompletionEngines)) {
|
||||
engine = new engine();
|
||||
if (engine.match(searchUrl))
|
||||
return this.engineCache.set(searchUrl, engine);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// True if we have a completion engine for this search URL, false otherwise.
|
||||
haveCompletionEngine(searchUrl) {
|
||||
return !this.lookupEngine(searchUrl).dummy;
|
||||
},
|
||||
|
||||
// This is the main entry point.
|
||||
// - searchUrl is the search engine's URL, e.g. Settings.get("searchUrl"), or a custom search engine's URL.
|
||||
// This is only used as a key for determining the relevant completion engine.
|
||||
// - queryTerms are the query terms.
|
||||
// - callback will be applied to a list of suggestion strings (which may be an empty list, if anything goes
|
||||
// wrong).
|
||||
//
|
||||
// If no callback is provided, then we're to provide suggestions only if we can do so synchronously (ie.
|
||||
// from a cache). In this case we just return the results. Returns null if we cannot service the request
|
||||
// synchronously.
|
||||
//
|
||||
complete(searchUrl, queryTerms, callback = null) {
|
||||
let handler;
|
||||
const query = queryTerms.join(" ").toLowerCase();
|
||||
|
||||
const returnResultsOnlyFromCache = (callback == null);
|
||||
if (callback == null) { callback = suggestions => suggestions; }
|
||||
|
||||
// We don't complete queries which are too short: the results are usually useless.
|
||||
if (query.length < 4)
|
||||
return callback([]);
|
||||
|
||||
// We don't complete regular URLs or Javascript URLs.
|
||||
if (queryTerms.length == 1 && Utils.isUrl(query))
|
||||
return callback([]);
|
||||
if (Utils.hasJavascriptPrefix(query))
|
||||
return callback([]);
|
||||
|
||||
const completionCacheKey = JSON.stringify([ searchUrl, queryTerms ]);
|
||||
if (this.completionCache.has(completionCacheKey)) {
|
||||
if (this.debug)
|
||||
console.log("hit", completionCacheKey);
|
||||
return callback(this.completionCache.get(completionCacheKey));
|
||||
}
|
||||
|
||||
// If the user appears to be typing a continuation of the characters of the most recent query, then we can
|
||||
// sometimes re-use the previous suggestions.
|
||||
if ((this.mostRecentQuery != null) && (this.mostRecentSuggestions != null) && (this.mostRecentSearchUrl != null)) {
|
||||
if (searchUrl === this.mostRecentSearchUrl) {
|
||||
const reusePreviousSuggestions = (() => {
|
||||
// Verify that the previous query is a prefix of the current query.
|
||||
if (!query.startsWith(this.mostRecentQuery.toLowerCase()))
|
||||
return false;
|
||||
// Verify that every previous suggestion contains the text of the new query.
|
||||
// Note: @mostRecentSuggestions may also be empty, in which case we drop though. The effect is that
|
||||
// previous queries with no suggestions suppress subsequent no-hope HTTP requests as the user
|
||||
// continues to type.
|
||||
for (let suggestion of this.mostRecentSuggestions)
|
||||
if (!suggestion.includes(query))
|
||||
return false;
|
||||
// Ok. Re-use the suggestion.
|
||||
return true;
|
||||
})();
|
||||
|
||||
if (reusePreviousSuggestions) {
|
||||
if (this.debug)
|
||||
console.log("reuse previous query:", this.mostRecentQuery, this.mostRecentSuggestions.length);
|
||||
return callback(this.completionCache.set(completionCacheKey, this.mostRecentSuggestions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// That's all of the caches we can try. Bail if the caller is only requesting synchronous results. We
|
||||
// signal that we haven't found a match by returning null.
|
||||
if (returnResultsOnlyFromCache)
|
||||
return callback(null);
|
||||
|
||||
// We pause in case the user is still typing.
|
||||
Utils.setTimeout(this.delay, (handler = (this.mostRecentHandler = () => {
|
||||
if (handler !== this.mostRecentHandler)
|
||||
return;
|
||||
this.mostRecentHandler = null;
|
||||
|
||||
// Elide duplicate requests. First fetch the suggestions...
|
||||
if (this.inTransit[completionCacheKey] == null) {
|
||||
this.inTransit[completionCacheKey] = new AsyncDataFetcher(callback => {
|
||||
const engine = new EnginePrefixWrapper(searchUrl, this.lookupEngine(searchUrl));
|
||||
const url = engine.getUrl(queryTerms);
|
||||
|
||||
// TODO(philc): Do we need to return the result of this.get here, or can we remove this return statement?
|
||||
return this.get(searchUrl, url, (xhr = null) => {
|
||||
// Parsing the response may fail if we receive an unexpected or an unexpectedly-formatted response.
|
||||
// In all cases, we fall back to the catch clause, below. Therefore, we "fail safe" in the case of
|
||||
// incorrect or out-of-date completion engines.
|
||||
let suggestions;
|
||||
try {
|
||||
suggestions = engine.parse(xhr)
|
||||
// Make all suggestions lower case. It looks odd when suggestions from one completion engine are
|
||||
// upper case, and those from another are lower case.
|
||||
.map(s => s.toLowerCase())
|
||||
// Filter out the query itself. It's not adding anything.
|
||||
.filter(s => s !== query);
|
||||
if (this.debug)
|
||||
console.log("GET", url);
|
||||
} catch (error) {
|
||||
suggestions = [];
|
||||
// We allow failures to be cached too, but remove them after just thirty seconds.
|
||||
Utils.setTimeout(30 * 1000, () => this.completionCache.set(completionCacheKey, null));
|
||||
if (this.debug)
|
||||
console.log("fail", url);
|
||||
}
|
||||
|
||||
callback(suggestions);
|
||||
delete this.inTransit[completionCacheKey];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ... then use the suggestions.
|
||||
this.inTransit[completionCacheKey].use(suggestions => {
|
||||
this.mostRecentSearchUrl = searchUrl;
|
||||
this.mostRecentQuery = query;
|
||||
this.mostRecentSuggestions = suggestions;
|
||||
// TODO(philc): Is this return necessary?
|
||||
return callback(this.completionCache.set(completionCacheKey, suggestions));
|
||||
});
|
||||
})));
|
||||
},
|
||||
|
||||
// Cancel any pending (ie. blocked on @delay) queries. Does not cancel in-flight queries. This is called
|
||||
// whenever the user is typing.
|
||||
cancel() {
|
||||
if (this.mostRecentHandler != null) {
|
||||
this.mostRecentHandler = null;
|
||||
if (this.debug)
|
||||
console.log("cancel (user is typing)");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.CompletionSearch = CompletionSearch;
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
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);
|
||||
},
|
||||
|
||||
// TODO(philc): Why does this take a `rules` argument if it's unused? Remove.
|
||||
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);
|
||||
|
||||
window.Exclusions = Exclusions;
|
||||
|
|
@ -0,0 +1,733 @@
|
|||
// 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 = {};
|
||||
window.portsForTab = {};
|
||||
window.urlForTab = {};
|
||||
|
||||
// This is exported for use by "marks.js".
|
||||
window.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.firefoxVersion() instanceof Promise
|
||||
|| (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}));
|
||||
const message = {handler: "registerFrameId", chromeFrameId: frameId}
|
||||
let firefoxVersion
|
||||
if (Utils.isFirefox()) {
|
||||
firefoxVersion = Utils.firefoxVersion()
|
||||
message.firefoxVersion = firefoxVersion
|
||||
}
|
||||
if (typeof firefoxVersion === "object") {
|
||||
firefoxVersion.then(() => {
|
||||
message.firefoxVersion = Utils.firefoxVersion()
|
||||
port.postMessage(message);
|
||||
})
|
||||
} else {
|
||||
port.postMessage(message);
|
||||
}
|
||||
(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(window, {TabOperations, Frames});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
const Marks = {
|
||||
// This returns the key which is used for storing mark locations in chrome.storage.sync.
|
||||
getLocationKey(markName) { return `vimiumGlobalMark|${markName}`; },
|
||||
|
||||
// Get the part of a URL we use for matching here (that is, everything up to the first anchor).
|
||||
getBaseUrl(url) { return url.split("#")[0]; },
|
||||
|
||||
// Create a global mark. We record vimiumSecret with the mark so that we can tell later, when the mark is
|
||||
// used, whether this is the original Vimium session or a subsequent session. This affects whether or not
|
||||
// tabId can be considered valid.
|
||||
create(req, sender) {
|
||||
chrome.storage.local.get("vimiumSecret", items => {
|
||||
const markInfo = {
|
||||
vimiumSecret: items.vimiumSecret,
|
||||
markName: req.markName,
|
||||
url: this.getBaseUrl(sender.tab.url),
|
||||
tabId: sender.tab.id,
|
||||
scrollX: req.scrollX,
|
||||
scrollY: req.scrollY
|
||||
};
|
||||
|
||||
if ((markInfo.scrollX != null) && (markInfo.scrollY != null)) {
|
||||
return this.saveMark(markInfo);
|
||||
} else {
|
||||
// The front-end frame hasn't provided the scroll position (because it's not the top frame within its
|
||||
// tab). We need to ask the top frame what its scroll position is.
|
||||
return chrome.tabs.sendMessage(sender.tab.id, {name: "getScrollPosition"}, response => {
|
||||
return this.saveMark(Object.assign(markInfo,
|
||||
{scrollX: response.scrollX, scrollY: response.scrollY}));
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
saveMark(markInfo) {
|
||||
const item = {};
|
||||
item[this.getLocationKey(markInfo.markName)] = markInfo;
|
||||
return Settings.storage.set(item);
|
||||
},
|
||||
|
||||
// Goto a global mark. We try to find the original tab. If we can't find that, then we try to find another
|
||||
// tab with the original URL, and use that. And if we can't find such an existing tab, then we create a new
|
||||
// one. Whichever of those we do, we then set the scroll position to the original scroll position.
|
||||
goto(req, sender) {
|
||||
chrome.storage.local.get("vimiumSecret", items => {
|
||||
const {
|
||||
vimiumSecret
|
||||
} = items;
|
||||
const key = this.getLocationKey(req.markName);
|
||||
return Settings.storage.get(key, items => {
|
||||
const markInfo = items[key];
|
||||
if (markInfo.vimiumSecret !== vimiumSecret) {
|
||||
// This is a different Vimium instantiation, so markInfo.tabId is definitely out of date.
|
||||
return this.focusOrLaunch(markInfo, req);
|
||||
} else {
|
||||
// Check whether markInfo.tabId still exists. According to
|
||||
// https://developer.chrome.com/extensions/tabs, tab Ids are unqiue within a Chrome session. So, if
|
||||
// we find a match, we can use it.
|
||||
return chrome.tabs.get(markInfo.tabId, tab => {
|
||||
if (!chrome.runtime.lastError && tab && tab.url && (markInfo.url === this.getBaseUrl(tab.url))) {
|
||||
// The original tab still exists.
|
||||
return this.gotoPositionInTab(markInfo);
|
||||
} else {
|
||||
// The original tab no longer exists.
|
||||
return this.focusOrLaunch(markInfo, req);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// Focus an existing tab and scroll to the given position within it.
|
||||
gotoPositionInTab({ tabId, scrollX, scrollY }) {
|
||||
chrome.tabs.update(tabId, { active: true }, (tab) => {
|
||||
chrome.windows.update(tab.windowId, { focused: true });
|
||||
chrome.tabs.sendMessage(tabId, {name: "setScrollPosition", scrollX, scrollY});
|
||||
});
|
||||
},
|
||||
|
||||
// The tab we're trying to find no longer exists. We either find another tab with a matching URL and use it,
|
||||
// or we create a new tab.
|
||||
focusOrLaunch(markInfo, req) {
|
||||
// If we're not going to be scrolling to a particular position in the tab, then we choose all tabs with a
|
||||
// matching URL prefix. Otherwise, we require an exact match (because it doesn't make sense to scroll
|
||||
// unless there's an exact URL match).
|
||||
const query = markInfo.scrollX === markInfo.scrollY && markInfo.scrollY === 0 ? `${markInfo.url}*` : markInfo.url;
|
||||
return chrome.tabs.query({ url: query }, tabs => {
|
||||
if (tabs.length > 0) {
|
||||
// We have at least one matching tab. Pick one and go to it.
|
||||
return this.pickTab(tabs, tab => {
|
||||
return this.gotoPositionInTab(Object.assign(markInfo, {tabId: tab.id}));
|
||||
});
|
||||
} else {
|
||||
// There is no existing matching tab, we'll have to create one.
|
||||
return TabOperations.openUrlInNewTab(Object.assign(req, {url: this.getBaseUrl(markInfo.url)}), tab => {
|
||||
// Note. tabLoadedHandlers is defined in "main.js". The handler below will be called when the tab
|
||||
// is loaded, its DOM is ready and it registers with the background page.
|
||||
return tabLoadedHandlers[tab.id] =
|
||||
() => this.gotoPositionInTab(Object.assign(markInfo, {tabId: tab.id}));
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Given a list of tabs candidate tabs, pick one. Prefer tabs in the current window and tabs with shorter
|
||||
// (matching) URLs.
|
||||
pickTab(tabs, callback) {
|
||||
const tabPicker = function({ id }) {
|
||||
// Prefer tabs in the current window, if there are any.
|
||||
let tab;
|
||||
const tabsInWindow = tabs.filter(tab => tab.windowId === id);
|
||||
if (tabsInWindow.length > 0) { tabs = tabsInWindow; }
|
||||
// If more than one tab remains and the current tab is still a candidate, then don't pick the current
|
||||
// tab (because jumping to it does nothing).
|
||||
if (tabs.length > 1)
|
||||
tabs = tabs.filter(t => !t.active)
|
||||
|
||||
// Prefer shorter URLs.
|
||||
tabs.sort((a, b) => a.url.length - b.url.length);
|
||||
return callback(tabs[0]);
|
||||
};
|
||||
if (chrome.windows != null)
|
||||
return chrome.windows.getCurrent(tabPicker);
|
||||
else
|
||||
return tabPicker({id: undefined});
|
||||
}
|
||||
};
|
||||
|
||||
window.Marks = Marks;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
//
|
||||
// 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
|
||||
|
||||
// Allow to access to the clipboard through iframes.
|
||||
this.hudUI.iframeElement.allow = "clipboard-read; clipboard-write";
|
||||
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};
|
||||
}\
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
window.HUD = HUD;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.Marks = Marks;
|
||||
|
|
@ -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(window, {Mode, SuppressAllKeyboardEvents, CacheAllKeydownEvents});
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
window.PostFindMode = PostFindMode;
|
||||
window.FindMode = FindMode;
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.InsertMode = InsertMode;
|
||||
window.PassNextKeyMode = PassNextKeyMode;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
window.KeyHandlerMode = KeyHandlerMode;
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.NormalMode = NormalMode;
|
||||
window.NormalModeCommands = NormalModeCommands;
|
||||
|
|
@ -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().init());
|
||||
},
|
||||
|
||||
"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().init(); },
|
||||
"V"() { return new VisualLineMode().init(); },
|
||||
"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().init();
|
||||
},
|
||||
"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;
|
||||
}
|
||||
}
|
||||
|
||||
window.VisualMode = VisualMode;
|
||||
window.VisualLineMode = VisualLineMode;
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
||||
window.Scroller = Scroller;
|
||||
|
|
@ -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.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.UIComponent = UIComponent;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,495 @@
|
|||
//
|
||||
// This content script must be run prior to domReady so that we perform some operations very early.
|
||||
//
|
||||
|
||||
const 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() {
|
||||
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() {
|
||||
// TODO(philc): I think this workaround can be removed?
|
||||
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(request) {
|
||||
frameId = (root.frameId = (window.frameId = request.chromeFrameId));
|
||||
if (Utils.isFirefox()) {
|
||||
Utils.firefoxVersion = () => request.firefoxVersion
|
||||
}
|
||||
// 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);
|
||||
|
|
@ -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 }));
|
||||
}
|
||||
};
|
||||
|
||||
window.Vomnibar = Vomnibar;
|
||||
|
After Width: | Height: | Size: 5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 393 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 791 B |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
|
@ -0,0 +1,35 @@
|
|||
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
|
||||
async copy({data}) {
|
||||
const textArea = this._createTextArea();
|
||||
textArea.value = data.replace(/\xa0/g, " ");
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
await navigator.clipboard.writeText(textArea.value);
|
||||
document.body.removeChild(textArea);
|
||||
},
|
||||
|
||||
// Returns a string representing the clipboard contents. Supports rich text clipboard values.
|
||||
async paste() {
|
||||
const textArea = this._createTextArea("div"); // Use a <div> so Firefox pastes rich text.
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
const value = await navigator.clipboard.readText();
|
||||
document.body.removeChild(textArea);
|
||||
// When copying characters, they get converted to \xa0. Convert to space instead. See #2217.
|
||||
return value.replace(/\xa0/g, " ");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
window.Clipboard = Clipboard;
|
||||
|
|
@ -0,0 +1,580 @@
|
|||
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) {
|
||||
// In firefox prior to 96, simulating a click on an element would be blocked. In 96+,
|
||||
// extensions can trigger native click listeners on elements. See #3985.
|
||||
const mayBeBlocked = event === "click" && Utils.isFirefox() && parseInt(Utils.firefoxVersion()) < 96
|
||||
&& /^91\.[0-5](\.|$)/.test(Utils.firefoxVersion())
|
||||
const defaultActionShouldTrigger =
|
||||
mayBeBlocked && (Object.keys(modifiers).length === 0) &&
|
||||
(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 (mayBeBlocked && defaultActionShouldTrigger) {
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
window.DomUtils = DomUtils;
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
window.FindModeHistory = FindModeHistory;
|
||||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
window.HandlerStack = HandlerStack;
|
||||
window.handlerStack = new HandlerStack();
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
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",
|
||||
"\n": "enter" // on a keypress event of Ctrl+Enter, tested on Chrome 92 and Windows 10
|
||||
},
|
||||
|
||||
init() {
|
||||
// TODO(philc): Remove this guard clause once Deno has a userAgent.
|
||||
// https://github.com/denoland/deno/issues/14362
|
||||
// As of 2022-04-30, Deno does not have userAgent defined on navigator.
|
||||
if (navigator.userAgent == null) {
|
||||
this.platform = "Unknown";
|
||||
return;
|
||||
}
|
||||
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();
|
||||
|
||||
window.KeyboardUtils = KeyboardUtils;
|
||||
|
|
@ -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)));
|
||||
}
|
||||
};
|
||||
|
||||
window.Rect = Rect;
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
// 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).
|
||||
// TODO(philc): Is this still needed?
|
||||
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.
|
||||
// TODO(philc): 2022-04-30: can we now remove this Firefox-specific logic?
|
||||
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));
|
||||
}
|
||||
|
||||
window.Settings = Settings;
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
// 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Note(gdh1995): Info in navigator is not reliable, because sometimes browsers will provide fake values.
|
||||
// For example, when `privacy.resistFingerprinting` is enabled on `about:config` of Firefox.
|
||||
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 browser === "object" && browser.runtime.getURL("").startsWith("moz"));
|
||||
return () => isFirefox;
|
||||
})(),
|
||||
|
||||
// NOTE(gdh1995): Content scripts may access this only after `registerFrameId`
|
||||
firefoxVersion: (function() {
|
||||
// NOTE(mrmr1993): This only works in the background page.
|
||||
let ffVersion = undefined;
|
||||
if (browserInfo) {
|
||||
browserInfo.then(info => {
|
||||
ffVersion = info != null ? info.version : undefined
|
||||
browserInfo = undefined
|
||||
});
|
||||
}
|
||||
return () => ffVersion || browserInfo;
|
||||
})(),
|
||||
|
||||
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, "<").replace(/>/g, ">"); },
|
||||
|
||||
// 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) {
|
||||
// navigator.appVersion is missing in our unit tests.
|
||||
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(window, {Utils, SimpleCache, AsyncDataFetcher, JobRunner});
|
||||
|
|
@ -0,0 +1 @@
|
|||
1.1a8e785387cc9c5fa844b2f11bab76741a6da5c07f262761c880cc1ea5b2456a
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"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,
|
||||
"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.",
|
||||
"differential_fingerprint": "1.1a8e785387cc9c5fa844b2f11bab76741a6da5c07f262761c880cc1ea5b2456a",
|
||||
"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.2",
|
||||
"web_accessible_resources": [ "pages/vomnibar.html", "content_scripts/vimium.css", "pages/hud.html", "pages/help_dialog.html", "pages/completion_engines.html" ]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
|
||||
div#wrapper
|
||||
{
|
||||
width: 730px;
|
||||
}
|
||||
|
||||
h4, h5
|
||||
{
|
||||
color: #777;
|
||||
}
|
||||
|
||||
div.engine
|
||||
{
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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("");
|
||||
});
|
||||
|
|
@ -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="✖"></td>
|
||||
</tr>
|
||||
</template>
|
||||
|
|
@ -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="#">×</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>
|
||||
|
|
@ -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()).
|
||||
const 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.
|
||||
});
|
||||
|
||||
window.HelpDialog = HelpDialog;
|
||||
window.isVimiumHelpDialog = true;
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 ( ) 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, async function() {
|
||||
const focusedElement = document.activeElement;
|
||||
await Clipboard.copy(data);
|
||||
if (focusedElement != null)
|
||||
focusedElement.focus();
|
||||
window.parent.focus();
|
||||
UIComponentServer.postMessage({name: "unfocusIfFocused"});
|
||||
});
|
||||
},
|
||||
|
||||
pasteFromClipboard() {
|
||||
Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, async function() {
|
||||
const focusedElement = document.activeElement;
|
||||
const data = await 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();
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 (window.DomUtils) { // window.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.
|
||||
window.Options = Options
|
||||
window.isVimiumOptionsPage = true
|
||||
|
|
@ -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>
|
||||
|
|
@ -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");
|
||||
}
|
||||
};
|
||||
})()
|
||||
};
|
||||
|
||||
window.UIComponentServer = UIComponentServer;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 && (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.
|
||||
});
|
||||
|
||||
window.Vomnibar = Vomnibar;
|
||||