├── icons
├── check.png
├── icon128.png
├── icon16.png
├── icon48.png
├── vimium.png
├── icon48disabled.png
├── icon48partial.png
├── browser_action_enabled.png
├── browser_action_partial.png
├── browser_action_disabled.png
├── 300x300_browser_action_disabled.png
├── 300x300_browser_action_enabled.png
└── 300x300_browser_action_partial.png
├── .gitignore
├── pages
├── completion_engines.css
├── exclusions.html
├── logging.js
├── hud.html
├── vomnibar.html
├── blank.html
├── completion_engines.js
├── ui_component_server.js
├── logging.html
├── completion_engines.html
├── popup.html
├── vomnibar.css
├── options.css
├── help_dialog.html
├── hud.js
└── help_dialog.js
├── .github
├── pull_request_template.md
└── ISSUE_TEMPLATE
│ └── bug_report.md
├── content_scripts
├── file_urls.css
├── vomnibar.js
├── mode_insert.js
├── marks.js
├── mode_key_handler.js
├── ui_component.js
└── hud.js
├── tests
├── dom_tests
│ ├── dom_test_setup.js
│ ├── test_runner.js
│ ├── chrome.js
│ ├── vomnibar_test.js
│ ├── dom_tests.html
│ └── dom_utils_test.js
└── unit_tests
│ ├── test_helper.js
│ ├── exclusion_test.js
│ ├── settings_test.js
│ ├── handler_stack_test.js
│ ├── test_chrome_stubs.js
│ ├── commands_test.js
│ └── utils_test.js
├── test_harnesses
├── iframe.html
├── form.html
├── page_with_links.html
├── has_popup_and_link_hud.html
├── vomnibar.html
└── visibility_test.html
├── MIT-LICENSE.txt
├── lib
├── clipboard.js
├── find_mode_history.js
├── rect.js
├── keyboard_utils.js
└── handler_stack.js
├── CREDITS
├── manifest.json
├── background_scripts
├── exclusions.js
├── marks.js
├── bg_utils.js
└── completion_engines.js
├── CONTRIBUTING.md
├── README.md
└── make.js
/icons/check.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/check.png
--------------------------------------------------------------------------------
/icons/icon128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/icon128.png
--------------------------------------------------------------------------------
/icons/icon16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/icon16.png
--------------------------------------------------------------------------------
/icons/icon48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/icon48.png
--------------------------------------------------------------------------------
/icons/vimium.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/vimium.png
--------------------------------------------------------------------------------
/icons/icon48disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/icon48disabled.png
--------------------------------------------------------------------------------
/icons/icon48partial.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/icon48partial.png
--------------------------------------------------------------------------------
/icons/browser_action_enabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/browser_action_enabled.png
--------------------------------------------------------------------------------
/icons/browser_action_partial.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/browser_action_partial.png
--------------------------------------------------------------------------------
/icons/browser_action_disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/browser_action_disabled.png
--------------------------------------------------------------------------------
/icons/300x300_browser_action_disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/300x300_browser_action_disabled.png
--------------------------------------------------------------------------------
/icons/300x300_browser_action_enabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/300x300_browser_action_enabled.png
--------------------------------------------------------------------------------
/icons/300x300_browser_action_partial.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jasonlong/vimium/master/icons/300x300_browser_action_partial.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.un~
2 | *.swo
3 | *.swp
4 | *.crx
5 | *.sublime*
6 | options/*.js
7 | node_modules/*
8 | dist
9 | jscoverage.json
10 | tags
11 | .cake_task_cache
12 |
--------------------------------------------------------------------------------
/pages/completion_engines.css:
--------------------------------------------------------------------------------
1 |
2 | div#wrapper
3 | {
4 | width: 730px;
5 | }
6 |
7 | h4, h5
8 | {
9 | color: #777;
10 | }
11 |
12 | div.engine
13 | {
14 | margin-left: 20px;
15 | }
16 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Description
2 | Provide a rationale for this PR, and a reference to the corresponding issue, if there is one.
3 |
4 | Please review the "Which pull requests get merged?" section in `CONTRIBUTING.md`.
5 |
--------------------------------------------------------------------------------
/content_scripts/file_urls.css:
--------------------------------------------------------------------------------
1 | /* Chrome file:// URLs set draggable=true for links to files (CSS selector .icon.file). This automatically
2 | * sets -webkit-user-select: none, which disables selecting the file names and so prevents Vimium's search
3 | * from working as expected. Here, we reset the value back to default. */
4 | .icon.file {
5 | -webkit-user-select: auto !important;
6 | }
7 |
--------------------------------------------------------------------------------
/tests/dom_tests/dom_test_setup.js:
--------------------------------------------------------------------------------
1 | window.vimiumDomTestsAreRunning = true
2 |
3 | // Attach shoulda's functions -- like setup, context, should -- to the global namespace.
4 | Object.assign(window, shoulda);
5 |
6 | // Install frontend event handlers.
7 | Frame.registerFrameId({chromeFrameId: 0});
8 |
9 | // Shoulda.js doesn't support async code, so we try not to use any.
10 | // TODO(philc): This is outdated; we can consider using async tests now.
11 | Utils.nextTick = (func) => func()
12 |
13 | document.addEventListener("DOMContentLoaded", () => HUD.init());
14 |
--------------------------------------------------------------------------------
/tests/unit_tests/test_helper.js:
--------------------------------------------------------------------------------
1 | import * as shoulda from "../vendor/shoulda.js";
2 | import "../../lib/utils.js";
3 | import "./test_chrome_stubs.js";
4 |
5 | const shouldaSubset = {
6 | assert: shoulda.assert,
7 | context: shoulda.context,
8 | ensureCalled: shoulda.ensureCalled,
9 | setup: shoulda.setup,
10 | should: shoulda.should,
11 | shoulda: shoulda,
12 | stub: shoulda.stub,
13 | returns: shoulda.returns,
14 | tearDown: shoulda.tearDown,
15 | };
16 |
17 | // Attach shoulda's functions, like setup, context, should, to the global namespace.
18 | Object.assign(window, shouldaSubset);
19 |
--------------------------------------------------------------------------------
/test_harnesses/iframe.html:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 | so Firefox pastes rich text.
25 | document.body.appendChild(textArea);
26 | textArea.focus();
27 | document.execCommand("Paste");
28 | const value = textArea.innerText;
29 | document.body.removeChild(textArea);
30 | // When copying characters, they get converted to \xa0. Convert to space instead. See #2217.
31 | return value.replace(/\xa0/g, " ");
32 | }
33 | };
34 |
35 |
36 | window.Clipboard = Clipboard;
37 |
--------------------------------------------------------------------------------
/pages/blank.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
New Tab
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/pages/completion_engines.js:
--------------------------------------------------------------------------------
1 | const cleanUpRegexp = (re) => re.toString()
2 | .replace(/^\//, '')
3 | .replace(/\/$/, '')
4 | .replace(/\\\//g, "/");
5 |
6 | DomUtils.documentReady(function() {
7 | const html = [];
8 | for (let engine of CompletionEngines.slice(0, CompletionEngines.length-1)) {
9 | engine = new engine;
10 | html.push(`
${engine.constructor.name} \n`);
11 | html.push("
");
12 | if (engine.example.explanation)
13 | html.push(`
${engine.example.explanation}
`);
14 | if (engine.example.searchUrl && engine.example.keyword) {
15 | if (!engine.example.description)
16 | engine.example.description = engine.constructor.name;
17 | html.push("
");
18 | html.push("Example:");
19 | html.push("
");
20 | html.push(`${engine.example.keyword}: ${engine.example.searchUrl} ${engine.example.description}`);
21 | html.push(" ");
22 | html.push("");
23 | }
24 |
25 | if (engine.regexps) {
26 | html.push("
");
27 | html.push(`Regular expression${1 < engine.regexps.length ? 's' : ''}:`);
28 | html.push("
");
29 | for (let re of engine.regexps)
30 | html.push(`${cleanUpRegexp(re)}\n`);
31 | html.push(" ");
32 | html.push("");
33 | }
34 | html.push("
");
35 | }
36 |
37 | document.getElementById("engineList").innerHTML = html.join("");
38 | });
39 |
--------------------------------------------------------------------------------
/pages/ui_component_server.js:
--------------------------------------------------------------------------------
1 | // Fetch the Vimium secret, register the port received from the parent window, and stop listening for messages
2 | // on the window object. vimiumSecret is accessible only within the current instance of Vimium. So a
3 | // malicious host page trying to register its own port can do no better than guessing.
4 |
5 | var registerPort = function(event) {
6 | chrome.storage.local.get("vimiumSecret", function({vimiumSecret: secret}) {
7 | if ((event.source !== window.parent) || (event.data !== secret))
8 | return;
9 | UIComponentServer.portOpen(event.ports[0]);
10 | window.removeEventListener("message", registerPort);
11 | });
12 | };
13 | window.addEventListener("message", registerPort);
14 |
15 | var UIComponentServer = {
16 | ownerPagePort: null,
17 | handleMessage: null,
18 |
19 | portOpen(ownerPagePort) {
20 | this.ownerPagePort = ownerPagePort;
21 | this.ownerPagePort.onmessage = event => {
22 | if (this.handleMessage)
23 | return this.handleMessage(event);
24 | };
25 | this.registerIsReady();
26 | },
27 |
28 | registerHandler(handleMessage) {
29 | this.handleMessage = handleMessage;
30 | },
31 |
32 | postMessage(message) {
33 | if (this.ownerPagePort)
34 | this.ownerPagePort.postMessage(message);
35 | },
36 |
37 | hide() { this.postMessage("hide"); },
38 |
39 | // We require both that the DOM is ready and that the port has been opened before the UI component is ready.
40 | // These events can happen in either order. We count them, and notify the content script when we've seen
41 | // both.
42 | registerIsReady: (function() {
43 | let uiComponentIsReadyCount;
44 | if (document.readyState === "loading") {
45 | window.addEventListener("DOMContentLoaded", () => UIComponentServer.registerIsReady());
46 | uiComponentIsReadyCount = 0;
47 | } else {
48 | uiComponentIsReadyCount = 1;
49 | }
50 |
51 | return function() {
52 | if (++uiComponentIsReadyCount === 2) {
53 | if (window.frameId != null)
54 | this.postMessage({name: "setIframeFrameId", iframeFrameId: window.frameId});
55 | this.postMessage("uiComponentIsReady");
56 | }
57 | };
58 | })()
59 | };
60 |
61 | window.UIComponentServer = UIComponentServer;
62 |
--------------------------------------------------------------------------------
/test_harnesses/has_popup_and_link_hud.html:
--------------------------------------------------------------------------------
1 |
5 |
7 |
8 |
9 |
Link and popup HUD
10 |
62 |
63 |
67 |
68 |
69 |
70 |
Loading and popup HUD
71 |
Big link
72 |
73 |
74 |
75 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/CREDITS:
--------------------------------------------------------------------------------
1 | Authors & Maintainers:
2 | Ilya Sukhar
(github: ilya)
3 | Phil Crosby (github: philc)
4 |
5 | Contributors:
6 | acrollet
7 | Adam Lindberg (github: eproxus)
8 | akhilman
9 | Ângelo Otávio Nuffer Nunes (github: angelonuffer)
10 | Bernardo B. Marques (github: bernardofire)
11 | Bill Casarin (github: jb55)
12 | Bill Mill (github: llimllib)
13 | Branden Rolston (github: branden)
14 | Caleb Spare (github: cespare)
15 | Carl Helmertz (github: chelmertz)
16 | Christian Stefanescu (github: stchris)
17 | ConradIrwin
18 | Daniel MacDougall (github: dmacdougall)
19 | drizzd
20 | gpurkins
21 | hogelog
22 | int3
23 | Johannes Emerich (github: knuton)
24 | Julian Naydichev (github: naydichev)
25 | Justin Blake (github: blaix)
26 | Knorkebrot
27 | lack
28 | markstos
29 | Matthew Cline
30 | Matt Garriott (github: mgarriott)
31 | Matthew Ryan (github: mrmr1993)
32 | Michael Hauser-Raspe (github: mijoharas)
33 | Murph (github: pandeiro)
34 | Niklas Baumstark (github: niklasb)
35 | rodimius
36 | Stephen Blott (github: smblott-github)
37 | Svein-Erik Larsen (github: feinom)
38 | Tim Morgan (github: seven1m)
39 | tsigo
40 | R.T. Lechow (github: rtlechow)
41 | Wang Ning (github:daning)
42 | Werner Laurensse (github: ab3)
43 | Timo Sand (github: deiga)
44 | Shiyong Chen (github: UncleBill)
45 | Utkarsh Upadhyay (github: PrestanceDesign)
47 | Dahan Gong (github: gdh1995)
48 | Scott Pinkelman (github: sco-tt)
49 | Darryl Pogue (github: dpogue)
50 | tobimensch
51 | Ramiro Araujo (github: ramiroaraujo)
52 | Daniel Skogly (github: poacher2k)
53 | Matt Wanchap (github: mwanchap)
54 | Leo Solidum (github: leosolid)
55 |
56 | Feel free to add real names in addition to GitHub usernames.
57 |
--------------------------------------------------------------------------------
/pages/logging.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Vimium Logging
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | Version:
64 | Installed:
65 |
Branch:
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/pages/completion_engines.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Vimium Search Completion
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | Search completion is available for custom search engines whose search URL matches one of Vimium's
38 | built-in completion engines; that is, the search URL matches one of the regular expressions below.
39 | Search completion is not available for the default search engine.
40 |
41 |
42 | Custom search engines can be configured on the options
43 | page.
44 | Further information is available on the wiki .
45 |
46 |
Available Completion Engines
47 |
48 | Search completion is available in this version of Vimium for the the following custom search engines.
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/pages/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/tests/dom_tests/vomnibar_test.js:
--------------------------------------------------------------------------------
1 | let vomnibarFrame = null;
2 | Vomnibar.init();
3 |
4 | context("Keep selection within bounds", () => {
5 |
6 | setup(() => {
7 | this.completions = [];
8 |
9 | vomnibarFrame = Vomnibar.vomnibarUI.iframeElement.contentWindow;
10 |
11 | // The Vomnibar frame is dynamically injected, so inject our stubs here.
12 | vomnibarFrame.chrome = chrome;
13 |
14 | const oldGetCompleter = vomnibarFrame.Vomnibar.getCompleter.bind(vomnibarFrame.Vomnibar);
15 | stub(vomnibarFrame.Vomnibar, 'getCompleter', name => {
16 | const completer = oldGetCompleter(name);
17 | stub(completer, 'filter', ({ callback }) => callback({results: this.completions}));
18 | return completer;
19 | });
20 |
21 | // Shoulda.js doesn't support async tests, so we have to hack around.
22 | stub(Vomnibar.vomnibarUI, "hide", () => {});
23 | stub(Vomnibar.vomnibarUI, "postMessage", data => vomnibarFrame.UIComponentServer.handleMessage({data}));
24 | stub(vomnibarFrame.UIComponentServer, "postMessage", data => UIComponent.handleMessage({data}));}),
25 |
26 | tearDown(() => Vomnibar.vomnibarUI.hide()),
27 |
28 | should("set selection to position -1 for omni completion by default", () => {
29 | Vomnibar.activate(0, {options: {}});
30 | const ui = vomnibarFrame.Vomnibar.vomnibarUI;
31 |
32 | this.completions = [];
33 | ui.update(true);
34 | assert.equal(-1, ui.selection);
35 |
36 | this.completions = [{html:'foo',type:'tab',url:'http://example.com'}];
37 | ui.update(true);
38 | assert.equal(-1, ui.selection);
39 |
40 | this.completions = [];
41 | ui.update(true);
42 | assert.equal(-1, ui.selection);
43 | });
44 |
45 | should("set selection to position 0 for bookmark completion if possible", () => {
46 | Vomnibar.activateBookmarks();
47 | const ui = vomnibarFrame.Vomnibar.vomnibarUI;
48 |
49 | this.completions = [];
50 | ui.update(true);
51 | assert.equal(-1, ui.selection);
52 |
53 | this.completions = [{html:'foo',type:'bookmark',url:'http://example.com'}];
54 | ui.update(true);
55 | assert.equal(0, ui.selection);
56 |
57 | this.completions = [];
58 | ui.update(true);
59 | assert.equal(-1, ui.selection);
60 | });
61 |
62 | should("keep selection within bounds", () => {
63 | Vomnibar.activate(0, {options: {}});
64 | const ui = vomnibarFrame.Vomnibar.vomnibarUI;
65 |
66 | this.completions = [];
67 | ui.update(true);
68 |
69 | const eventMock = {
70 | preventDefault() {},
71 | stopImmediatePropagation() {}
72 | };
73 |
74 | this.completions = [{html:'foo',type:'tab',url:'http://example.com'}];
75 | ui.update(true);
76 | stub(ui, "actionFromKeyEvent", () => "down");
77 | ui.onKeyEvent(eventMock);
78 | assert.equal(0, ui.selection);
79 |
80 | this.completions = [];
81 | ui.update(true);
82 | assert.equal(-1, ui.selection);
83 | })
84 | });
85 |
--------------------------------------------------------------------------------
/content_scripts/vomnibar.js:
--------------------------------------------------------------------------------
1 | //
2 | // This wraps the vomnibar iframe, which we inject into the page to provide the vomnibar.
3 | //
4 | const Vomnibar = {
5 | vomnibarUI: null,
6 |
7 | // Extract any additional options from the command's registry entry.
8 | extractOptionsFromRegistryEntry(registryEntry, callback) {
9 | return callback ? callback(Object.assign({}, registryEntry.options)) : null;
10 | },
11 |
12 | // sourceFrameId here (and below) is the ID of the frame from which this request originates, which may be
13 | // different from the current frame.
14 |
15 | activate(sourceFrameId, registryEntry) {
16 | return this.extractOptionsFromRegistryEntry(registryEntry, options => {
17 | return this.open(sourceFrameId, Object.assign(options, {completer:"omni"}));
18 | });
19 | },
20 |
21 | activateInNewTab(sourceFrameId, registryEntry) {
22 | return this.extractOptionsFromRegistryEntry(registryEntry, options => {
23 | return this.open(sourceFrameId, Object.assign(options, {completer:"omni", newTab: true}));
24 | });
25 | },
26 |
27 | activateTabSelection(sourceFrameId) {
28 | return this.open(sourceFrameId, {
29 | completer: "tabs",
30 | selectFirst: true
31 | });
32 | },
33 |
34 | activateBookmarks(sourceFrameId) {
35 | return this.open(sourceFrameId, {
36 | completer: "bookmarks",
37 | selectFirst: true
38 | });
39 | },
40 |
41 | activateBookmarksInNewTab(sourceFrameId) {
42 | return this.open(sourceFrameId, {
43 | completer: "bookmarks",
44 | selectFirst: true,
45 | newTab: true
46 | });
47 | },
48 |
49 | activateEditUrl(sourceFrameId) {
50 | return this.open(sourceFrameId, {
51 | completer: "omni",
52 | selectFirst: false,
53 | query: window.location.href
54 | });
55 | },
56 |
57 | activateEditUrlInNewTab(sourceFrameId) {
58 | return this.open(sourceFrameId, {
59 | completer: "omni",
60 | selectFirst: false,
61 | query: window.location.href,
62 | newTab: true
63 | });
64 | },
65 |
66 | init() {
67 | if (!this.vomnibarUI)
68 | this.vomnibarUI = new UIComponent("pages/vomnibar.html", "vomnibarFrame", function() {})
69 | },
70 |
71 | // This function opens the vomnibar. It accepts options, a map with the values:
72 | // completer - The completer to fetch results from.
73 | // query - Optional. Text to prefill the Vomnibar with.
74 | // selectFirst - Optional, boolean. Whether to select the first entry.
75 | // newTab - Optional, boolean. Whether to open the result in a new tab.
76 | open(sourceFrameId, options) {
77 | this.init();
78 | // The Vomnibar cannot coexist with the help dialog (it causes focus issues).
79 | HelpDialog.abort();
80 | return this.vomnibarUI.activate(Object.assign(options, { name: "activate", sourceFrameId, focus: true }));
81 | }
82 | };
83 |
84 | window.Vomnibar = Vomnibar;
85 |
--------------------------------------------------------------------------------
/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 2,
3 | "name": "Vimium",
4 | "version": "1.67.1",
5 | "description": "The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.",
6 | "icons": { "16": "icons/icon16.png",
7 | "48": "icons/icon48.png",
8 | "128": "icons/icon128.png" },
9 | "minimum_chrome_version": "69.0",
10 | "background": {
11 | "scripts": [
12 | "lib/utils.js",
13 | "lib/settings.js",
14 | "background_scripts/bg_utils.js",
15 | "background_scripts/commands.js",
16 | "background_scripts/exclusions.js",
17 | "background_scripts/completion_engines.js",
18 | "background_scripts/completion_search.js",
19 | "background_scripts/completion.js",
20 | "background_scripts/marks.js",
21 | "background_scripts/main.js"
22 | ]
23 | },
24 | "options_ui": {
25 | "page": "pages/options.html",
26 | "chrome_style": false,
27 | "open_in_tab": true
28 | },
29 | "permissions": [
30 | "tabs",
31 | "bookmarks",
32 | "history",
33 | "clipboardRead",
34 | "clipboardWrite",
35 | "storage",
36 | "sessions",
37 | "notifications",
38 | "webNavigation",
39 | ""
40 | ],
41 | "content_scripts": [
42 | {
43 | "matches": [""],
44 | "js": ["lib/utils.js",
45 | "lib/keyboard_utils.js",
46 | "lib/dom_utils.js",
47 | "lib/rect.js",
48 | "lib/handler_stack.js",
49 | "lib/settings.js",
50 | "lib/find_mode_history.js",
51 | "content_scripts/mode.js",
52 | "content_scripts/ui_component.js",
53 | "content_scripts/link_hints.js",
54 | "content_scripts/vomnibar.js",
55 | "content_scripts/scroller.js",
56 | "content_scripts/marks.js",
57 | "content_scripts/mode_insert.js",
58 | "content_scripts/mode_find.js",
59 | "content_scripts/mode_key_handler.js",
60 | "content_scripts/mode_visual.js",
61 | "content_scripts/hud.js",
62 | "content_scripts/mode_normal.js",
63 | "content_scripts/vimium_frontend.js"
64 | ],
65 | "css": ["content_scripts/vimium.css"],
66 | "run_at": "document_start",
67 | "all_frames": true,
68 | "match_about_blank": true
69 | },
70 | {
71 | "matches": ["file:///", "file:///*/"],
72 | "css": ["content_scripts/file_urls.css"],
73 | "run_at": "document_start",
74 | "all_frames": true
75 | }
76 | ],
77 | "browser_action": {
78 | "default_icon": "icons/browser_action_disabled.png",
79 | "default_popup": "pages/popup.html"
80 | },
81 | "web_accessible_resources": [
82 | "pages/vomnibar.html",
83 | "content_scripts/vimium.css",
84 | "pages/hud.html",
85 | "pages/help_dialog.html",
86 | "pages/completion_engines.html"
87 | ]
88 | }
89 |
--------------------------------------------------------------------------------
/lib/find_mode_history.js:
--------------------------------------------------------------------------------
1 | // NOTE(mrmr1993): This is under lib/ since it is used by both content scripts and iframes from pages/.
2 | // This implements find-mode query history (using the "findModeRawQueryList" setting) as a list of raw queries,
3 | // most recent first.
4 | const FindModeHistory = {
5 | storage: (typeof chrome !== 'undefined' && chrome !== null ? chrome.storage.local : undefined), // Guard against chrome being undefined (in the HUD iframe).
6 | key: "findModeRawQueryList",
7 | max: 50,
8 | rawQueryList: null,
9 |
10 | init() {
11 | this.isIncognitoMode = typeof chrome !== 'undefined' && chrome !== null ? chrome.extension.inIncognitoContext : undefined;
12 |
13 | if (this.isIncognitoMode == null) { return; } // chrome is undefined in the HUD iframe during tests, so we do nothing.
14 |
15 | if (!this.rawQueryList) {
16 | this.rawQueryList = []; // Prevent repeated initialization.
17 | if (this.isIncognitoMode) { this.key = "findModeRawQueryListIncognito"; }
18 | this.storage.get(this.key, items => {
19 | if (!chrome.runtime.lastError) {
20 | if (items[this.key]) { this.rawQueryList = items[this.key]; }
21 | if (this.isIncognitoMode && !items[this.key]) {
22 | // This is the first incognito tab, so we need to initialize the incognito-mode query history.
23 | this.storage.get("findModeRawQueryList", items => {
24 | if (!chrome.runtime.lastError) {
25 | this.rawQueryList = items.findModeRawQueryList;
26 | this.storage.set({findModeRawQueryListIncognito: this.rawQueryList});
27 | }
28 | });
29 | }
30 | }
31 | });
32 | }
33 |
34 | chrome.storage.onChanged.addListener((changes, area) => {
35 | if (changes[this.key]) { this.rawQueryList = changes[this.key].newValue; }
36 | });
37 | },
38 |
39 | getQuery(index) {
40 | if (index == null) { index = 0; }
41 | return this.rawQueryList[index] || "";
42 | },
43 |
44 | saveQuery(query) {
45 | if (0 < query.length) {
46 | this.rawQueryList = this.refreshRawQueryList(query, this.rawQueryList);
47 | const newSetting = {};
48 | newSetting[this.key] = this.rawQueryList;
49 | this.storage.set(newSetting);
50 | // If there are any active incognito-mode tabs, then propagte this query to those tabs too.
51 | if (!this.isIncognitoMode) {
52 | this.storage.get("findModeRawQueryListIncognito", items => {
53 | if (!chrome.runtime.lastError && items.findModeRawQueryListIncognito) {
54 | this.storage.set({
55 | findModeRawQueryListIncognito: this.refreshRawQueryList(query, items.findModeRawQueryListIncognito)});
56 | }
57 | });
58 | }
59 | }
60 | },
61 |
62 | refreshRawQueryList(query, rawQueryList) {
63 | return ([query].concat(rawQueryList.filter(q => q !== query))).slice(0, this.max + 1);
64 | }
65 | };
66 |
67 | window.FindModeHistory = FindModeHistory;
68 |
--------------------------------------------------------------------------------
/tests/unit_tests/exclusion_test.js:
--------------------------------------------------------------------------------
1 | import "./test_helper.js";
2 |
3 | Utils.getCurrentVersion = () => "1.44";
4 |
5 | import "../../lib/settings.js";
6 | import "../../lib/clipboard.js";
7 | import "../../background_scripts/bg_utils.js";
8 | import "../../background_scripts/exclusions.js";
9 | import "../../background_scripts/commands.js";
10 |
11 | const isEnabledForUrl = (request) => Exclusions.isEnabledForUrl(request.url);
12 |
13 | // These tests cover only the most basic aspects of excluded URLs and passKeys.
14 | context("Excluded URLs and pass keys", () => {
15 |
16 | setup(() => {
17 | Settings.init();
18 | Settings.set("exclusionRules",
19 | [
20 | { pattern: "http*://mail.google.com/*", passKeys: "" },
21 | { pattern: "http*://www.facebook.com/*", passKeys: "abab" },
22 | { pattern: "http*://www.facebook.com/*", passKeys: "cdcd" },
23 | { pattern: "http*://www.bbc.com/*", passKeys: "" },
24 | { pattern: "http*://www.bbc.com/*", passKeys: "ab" },
25 | { pattern: "http*://www.example.com/*", passKeys: "a bb c bba a" },
26 | { pattern: "http*://www.duplicate.com/*", passKeys: "ace" },
27 | { pattern: "http*://www.duplicate.com/*", passKeys: "bdf" }
28 | ]);
29 | Exclusions.postUpdateHook();
30 | });
31 |
32 | should("be disabled for excluded sites", () => {
33 | const rule = isEnabledForUrl({ url: 'http://mail.google.com/calendar/page' });
34 | assert.isFalse(rule.isEnabledForUrl);
35 | assert.isFalse(rule.passKeys);
36 | });
37 |
38 | should("be disabled for excluded sites, one exclusion", () => {
39 | const rule = isEnabledForUrl({ url: 'http://www.bbc.com/calendar/page' });
40 | assert.isFalse(rule.isEnabledForUrl);
41 | assert.isFalse(rule.passKeys);
42 | });
43 |
44 | should("be enabled, but with pass keys", () => {
45 | const rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' });
46 | assert.isTrue(rule.isEnabledForUrl);
47 | assert.equal(rule.passKeys, 'abcd');
48 | });
49 |
50 | should("be enabled", () => {
51 | const rule = isEnabledForUrl({ url: 'http://www.twitter.com/pages' });
52 | assert.isTrue(rule.isEnabledForUrl);
53 | assert.isFalse(rule.passKeys);
54 | });
55 |
56 | should("handle spaces and duplicates in passkeys", () => {
57 | const rule = isEnabledForUrl({ url: 'http://www.example.com/pages' });
58 | assert.isTrue(rule.isEnabledForUrl);
59 | assert.equal("abc", rule.passKeys);
60 | });
61 |
62 | should("handle multiple passkeys rules", () => {
63 | const rule = isEnabledForUrl({ url: 'http://www.duplicate.com/pages' });
64 | assert.isTrue(rule.isEnabledForUrl);
65 | assert.equal("abcdef", rule.passKeys);
66 | });
67 |
68 | should("be enabled for malformed regular expressions", () => {
69 | Exclusions.postUpdateHook([ { pattern: "http*://www.bad-regexp.com/*[a-", passKeys: "" } ]);
70 | const rule = isEnabledForUrl({ url: 'http://www.bad-regexp.com/pages' });
71 | assert.isTrue(rule.isEnabledForUrl);
72 | })
73 | });
74 |
--------------------------------------------------------------------------------
/test_harnesses/vomnibar.html:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
41 |
42 |
52 |
53 |
54 |
55 |
56 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et
57 | dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
58 | ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
59 | nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
60 | anim id est laborum.
61 |
62 |
63 |
64 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et
65 | dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
66 | ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
67 | nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
68 | anim id est laborum.
69 |
70 |
71 |
--------------------------------------------------------------------------------
/background_scripts/exclusions.js:
--------------------------------------------------------------------------------
1 | const ExclusionRegexpCache = {
2 | cache: {},
3 | clear(cache) {
4 | this.cache = cache || {};
5 | },
6 | get(pattern) {
7 | if (pattern in this.cache) {
8 | return this.cache[pattern];
9 | } else {
10 | let result;
11 | // We use try/catch to ensure that a broken regexp doesn't wholly cripple Vimium.
12 | try {
13 | result = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
14 | } catch (error) {
15 | BgUtils.log(`bad regexp in exclusion rule: ${pattern}`);
16 | result = /^$/; // Match the empty string.
17 | }
18 | this.cache[pattern] = result;
19 | return result;
20 | }
21 | }
22 | };
23 |
24 | // The Exclusions class manages the exclusion rule setting. An exclusion is an object with two attributes:
25 | // pattern and passKeys. The exclusion rules are an array of such objects.
26 | var Exclusions = {
27 | // Make RegexpCache, which is required on the page popup, accessible via the Exclusions object.
28 | RegexpCache: ExclusionRegexpCache,
29 |
30 | rules: Settings.get("exclusionRules"),
31 |
32 | // Merge the matching rules for URL, or null. In the normal case, we use the configured @rules; hence, this
33 | // is the default. However, when called from the page popup, we are testing what effect candidate new rules
34 | // would have on the current tab. In this case, the candidate rules are provided by the caller.
35 | getRule(url, rules) {
36 | if (rules == null)
37 | rules = this.rules;
38 | const matchingRules = rules.filter(r => r.pattern && (url.search(ExclusionRegexpCache.get(r.pattern)) >= 0));
39 | // An absolute exclusion rule (one with no passKeys) takes priority.
40 | for (let rule of matchingRules)
41 | if (!rule.passKeys)
42 | return rule;
43 | // Strip whitespace from all matching passKeys strings, and join them together.
44 | const passKeys = matchingRules.map(r => r.passKeys.split(/\s+/).join("")).join("");
45 | // passKeys = (rule.passKeys.split(/\s+/).join "" for rule in matchingRules).join ""
46 | if (matchingRules.length > 0)
47 | return {passKeys: Utils.distinctCharacters(passKeys)};
48 | else
49 | return null;
50 | },
51 |
52 | isEnabledForUrl(url) {
53 | const rule = Exclusions.getRule(url);
54 | return {
55 | isEnabledForUrl: !rule || (rule.passKeys.length > 0),
56 | passKeys: rule ? rule.passKeys : ""
57 | };
58 | },
59 |
60 | setRules(rules) {
61 | // Callers map a rule to null to have it deleted, and rules without a pattern are useless.
62 | this.rules = rules.filter(rule => rule && rule.pattern);
63 | Settings.set("exclusionRules", this.rules);
64 | },
65 |
66 | // TODO(philc): Why does this take a `rules` argument if it's unused? Remove.
67 | postUpdateHook(rules) {
68 | // NOTE(mrmr1993): In FF, the |rules| argument will be garbage collected when the exclusions popup is
69 | // closed. Do NOT store it/use it asynchronously.
70 | this.rules = Settings.get("exclusionRules");
71 | ExclusionRegexpCache.clear();
72 | }
73 | };
74 |
75 | // Register postUpdateHook for exclusionRules setting.
76 | Settings.postUpdateHooks["exclusionRules"] = Exclusions.postUpdateHook.bind(Exclusions);
77 |
78 | window.Exclusions = Exclusions;
79 |
--------------------------------------------------------------------------------
/lib/rect.js:
--------------------------------------------------------------------------------
1 | // Commands for manipulating rects.
2 | var Rect = {
3 | // Create a rect given the top left and bottom right corners.
4 | create(x1, y1, x2, y2) {
5 | return {
6 | bottom: y2,
7 | top: y1,
8 | left: x1,
9 | right: x2,
10 | width: x2 - x1,
11 | height: y2 - y1
12 | };
13 | },
14 |
15 | copy(rect) {
16 | return {
17 | bottom: rect.bottom,
18 | top: rect.top,
19 | left: rect.left,
20 | right: rect.right,
21 | width: rect.width,
22 | height: rect.height
23 | };
24 | },
25 |
26 | // Translate a rect by x horizontally and y vertically.
27 | translate(rect, x, y) {
28 | if (x == null) { x = 0; }
29 | if (y == null) { y = 0; }
30 | return {
31 | bottom: rect.bottom + y,
32 | top: rect.top + y,
33 | left: rect.left + x,
34 | right: rect.right + x,
35 | width: rect.width,
36 | height: rect.height
37 | };
38 | },
39 |
40 | // Subtract rect2 from rect1, returning an array of rects which are in rect1 but not rect2.
41 | subtract(rect1, rect2) {
42 | // Bound rect2 by rect1
43 | rect2 = this.create(
44 | Math.max(rect1.left, rect2.left),
45 | Math.max(rect1.top, rect2.top),
46 | Math.min(rect1.right, rect2.right),
47 | Math.min(rect1.bottom, rect2.bottom)
48 | );
49 |
50 | // If bounding rect2 has made the width or height negative, rect1 does not contain rect2.
51 | if ((rect2.width < 0) || (rect2.height < 0)) { return [Rect.copy(rect1)]; }
52 |
53 | //
54 | // All the possible rects, in the order
55 | // +-+-+-+
56 | // |1|2|3|
57 | // +-+-+-+
58 | // |4| |5|
59 | // +-+-+-+
60 | // |6|7|8|
61 | // +-+-+-+
62 | // where the outer rectangle is rect1 and the inner rectangle is rect 2. Note that the rects may be of
63 | // width or height 0.
64 | //
65 | const rects = [
66 | // Top row.
67 | this.create(rect1.left, rect1.top, rect2.left, rect2.top),
68 | this.create(rect2.left, rect1.top, rect2.right, rect2.top),
69 | this.create(rect2.right, rect1.top, rect1.right, rect2.top),
70 | // Middle row.
71 | this.create(rect1.left, rect2.top, rect2.left, rect2.bottom),
72 | this.create(rect2.right, rect2.top, rect1.right, rect2.bottom),
73 | // Bottom row.
74 | this.create(rect1.left, rect2.bottom, rect2.left, rect1.bottom),
75 | this.create(rect2.left, rect2.bottom, rect2.right, rect1.bottom),
76 | this.create(rect2.right, rect2.bottom, rect1.right, rect1.bottom)
77 | ];
78 |
79 | return rects.filter(rect => (rect.height > 0) && (rect.width > 0));
80 | },
81 |
82 | // Determine whether two rects overlap.
83 | intersects(rect1, rect2) {
84 | return (rect1.right > rect2.left) &&
85 | (rect1.left < rect2.right) &&
86 | (rect1.bottom > rect2.top) &&
87 | (rect1.top < rect2.bottom);
88 | },
89 |
90 | // Determine whether two rects overlap, including 0-width intersections at borders.
91 | intersectsStrict(rect1, rect2) {
92 | return (rect1.right >= rect2.left) && (rect1.left <= rect2.right) &&
93 | (rect1.bottom >= rect2.top) && (rect1.top <= rect2.bottom);
94 | },
95 |
96 | equals(rect1, rect2) {
97 | for (let property of ["top", "bottom", "left", "right", "width", "height"]) {
98 | if (rect1[property] !== rect2[property]) { return false; }
99 | }
100 | return true;
101 | },
102 |
103 | intersect(rect1, rect2) {
104 | return this.create((Math.max(rect1.left, rect2.left)), (Math.max(rect1.top, rect2.top)),
105 | (Math.min(rect1.right, rect2.right)), (Math.min(rect1.bottom, rect2.bottom)));
106 | }
107 | };
108 |
109 | window.Rect = Rect;
110 |
--------------------------------------------------------------------------------
/tests/dom_tests/dom_tests.html:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
29 |
30 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | Vimium Tests
70 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/tests/unit_tests/settings_test.js:
--------------------------------------------------------------------------------
1 | import "./test_helper.js";
2 | import "../../lib/settings.js";
3 | import "../../pages/options.js";
4 |
5 | context("settings", () => {
6 | setup(() => {
7 | stub(Utils, "isBackgroundPage", returns(true));
8 | stub(Utils, "isExtensionPage", returns(true));
9 |
10 | localStorage.clear();
11 | Settings.init();
12 | // Avoid running update hooks which include calls to outside of settings.
13 | Settings.postUpdateHooks = {};
14 | });
15 |
16 | should("save settings in localStorage as JSONified strings", () => {
17 | Settings.set('dummy', "");
18 | assert.equal('""', localStorage.dummy);
19 | });
20 |
21 | should("obtain defaults if no key is stored", () => {
22 | assert.isFalse(Settings.has('scrollStepSize'));
23 | assert.equal(60, Settings.get('scrollStepSize'));
24 | });
25 |
26 | should("store values", () => {
27 | Settings.set('scrollStepSize', 20);
28 | assert.equal(20, Settings.get('scrollStepSize'));
29 | });
30 |
31 | should("revert to defaults if no key is stored", () => {
32 | Settings.set('scrollStepSize', 20);
33 | Settings.clear('scrollStepSize');
34 | assert.equal(60, Settings.get('scrollStepSize'));
35 | });
36 |
37 | tearDown(() => {
38 | localStorage.clear();
39 | });
40 | });
41 |
42 | context("synced settings", () => {
43 | setup(() => {
44 | localStorage.clear();
45 | Settings.init();
46 | // Avoid running update hooks which include calls to outside of settings.
47 | Settings.postUpdateHooks = {};
48 | });
49 |
50 | should("propagate non-default value via synced storage listener", () => {
51 | Settings.set('scrollStepSize', 20);
52 | assert.equal(20, Settings.get('scrollStepSize'));
53 | Settings.propagateChangesFromChromeStorage({ scrollStepSize: { newValue: "40" } });
54 | assert.equal(40, Settings.get('scrollStepSize'));
55 | });
56 |
57 | should("propagate default value via synced storage listener", () => {
58 | Settings.set('scrollStepSize', 20);
59 | assert.equal(20, Settings.get('scrollStepSize'));
60 | Settings.propagateChangesFromChromeStorage({ scrollStepSize: { newValue: "60" } });
61 | assert.equal(60, Settings.get('scrollStepSize'));
62 | });
63 |
64 | should("propagate non-default values from synced storage", () => {
65 | chrome.storage.sync.set({ scrollStepSize: JSON.stringify(20) });
66 | assert.equal(20, Settings.get('scrollStepSize'));
67 | });
68 |
69 | should("propagate default values from synced storage", () => {
70 | Settings.set('scrollStepSize', 20);
71 | chrome.storage.sync.set({ scrollStepSize: JSON.stringify(60) });
72 | assert.equal(60, Settings.get('scrollStepSize'));
73 | });
74 |
75 | should("clear a setting from synced storage", () => {
76 | Settings.set('scrollStepSize', 20);
77 | chrome.storage.sync.remove('scrollStepSize');
78 | assert.equal(60, Settings.get('scrollStepSize'));
79 | });
80 |
81 | should("trigger a postUpdateHook", () => {
82 | const message = "Hello World";
83 | let receivedMessage = "";
84 | Settings.postUpdateHooks['scrollStepSize'] = value => receivedMessage = value;
85 | chrome.storage.sync.set({ scrollStepSize: JSON.stringify(message) });
86 | assert.equal(message, receivedMessage);
87 | });
88 |
89 | should("sync a key which is not a known setting (without crashing)", () => {
90 | chrome.storage.sync.set({ notASetting: JSON.stringify("notAUsefullValue") });
91 | });
92 |
93 | tearDown(() => {
94 | localStorage.clear();
95 | });
96 | });
97 |
98 | context("default values", () => {
99 | should("have a default value for every option", () => {
100 | for (let key of Object.keys(Options)) {
101 | assert.isTrue(key in Settings.defaults);
102 | }
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/content_scripts/mode_insert.js:
--------------------------------------------------------------------------------
1 | class InsertMode extends Mode {
2 | constructor(options) {
3 | super();
4 | if (options == null)
5 | options = {};
6 |
7 | // There is one permanently-installed instance of InsertMode. It tracks focus changes and
8 | // activates/deactivates itself (by setting @insertModeLock) accordingly.
9 | this.permanent = options.permanent;
10 |
11 | // If truthy, then we were activated by the user (with "i").
12 | this.global = options.global;
13 |
14 | const handleKeyEvent = event => {
15 | if (!this.isActive(event))
16 | return this.continueBubbling;
17 |
18 | // See comment here: https://github.com/philc/vimium/commit/48c169bd5a61685bb4e67b1e76c939dbf360a658.
19 | const activeElement = this.getActiveElement();
20 | if ((activeElement === document.body) && activeElement.isContentEditable)
21 | return this.passEventToPage;
22 |
23 | // Check for a pass-next-key key.
24 | const keyString = KeyboardUtils.getKeyCharString(event);
25 | if (Settings.get("passNextKeyKeys").includes(keyString)) {
26 | new PassNextKeyMode();
27 | } else if ((event.type === 'keydown') && KeyboardUtils.isEscape(event)) {
28 | if (DomUtils.isFocusable(activeElement))
29 | activeElement.blur();
30 |
31 | if (!this.permanent)
32 | this.exit();
33 |
34 | } else {
35 | return this.passEventToPage;
36 | }
37 |
38 | return this.suppressEvent;
39 | };
40 |
41 | const defaults = {
42 | name: "insert",
43 | indicator: !this.permanent && !Settings.get("hideHud") ? "Insert mode" : null,
44 | keypress: handleKeyEvent,
45 | keydown: handleKeyEvent
46 | };
47 |
48 | super.init(Object.assign(defaults, options));
49 |
50 | // Only for tests. This gives us a hook to test the status of the permanently-installed instance.
51 | if (this.permanent)
52 | InsertMode.permanentInstance = this;
53 | }
54 |
55 | isActive(event) {
56 | if (event === InsertMode.suppressedEvent)
57 | return false;
58 | if (this.global)
59 | return true;
60 | return DomUtils.isFocusable(this.getActiveElement());
61 | }
62 |
63 | getActiveElement() {
64 | let activeElement = document.activeElement;
65 | while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement)
66 | activeElement = activeElement.shadowRoot.activeElement;
67 | return activeElement;
68 | }
69 |
70 | static suppressEvent(event) { return this.suppressedEvent = event; }
71 | }
72 |
73 | // This allows PostFindMode to suppress the permanently-installed InsertMode instance.
74 | InsertMode.suppressedEvent = null;
75 |
76 | // This implements the pasNexKey command.
77 | class PassNextKeyMode extends Mode {
78 | constructor(count) {
79 | if (count == null)
80 | count = 1;
81 | super();
82 | let seenKeyDown = false;
83 | let keyDownCount = 0;
84 |
85 | super.init({
86 | name: "pass-next-key",
87 | indicator: "Pass next key.",
88 | // We exit on blur because, once we lose the focus, we can no longer track key events.
89 | exitOnBlur: window,
90 | keypress: () => {
91 | return this.passEventToPage;
92 | },
93 |
94 | keydown: () => {
95 | seenKeyDown = true;
96 | keyDownCount += 1;
97 | return this.passEventToPage;
98 | },
99 |
100 | keyup: () => {
101 | if (seenKeyDown) {
102 | if (!(--keyDownCount > 0)) {
103 | if (!(--count > 0)) {
104 | this.exit();
105 | }
106 | }
107 | }
108 | return this.passEventToPage;
109 | }
110 | });
111 | }
112 | }
113 |
114 | window.InsertMode = InsertMode;
115 | window.PassNextKeyMode = PassNextKeyMode;
116 |
--------------------------------------------------------------------------------
/tests/unit_tests/handler_stack_test.js:
--------------------------------------------------------------------------------
1 | import "./test_helper.js";
2 | import "../../lib/handler_stack.js";
3 |
4 | context("handlerStack", () => {
5 | let handlerStack, handler1Called, handler2Called;
6 |
7 | setup(() => {
8 | stub(window, "DomUtils", {});
9 | stub(DomUtils, "consumeKeyup", () => {});
10 | stub(DomUtils, "suppressEvent", () => {});
11 | stub(DomUtils, "suppressPropagation", () => {});
12 | handlerStack = new HandlerStack;
13 | handler1Called = false;
14 | handler2Called = false;
15 | });
16 |
17 | should("bubble events", () => {
18 | handlerStack.push({ keydown: () => { return handler1Called = true; } });
19 | handlerStack.push({ keydown: () => { return handler2Called = true; } });
20 | handlerStack.bubbleEvent('keydown', {});
21 | assert.isTrue(handler2Called);
22 | assert.isTrue(handler1Called);
23 | });
24 |
25 | should("terminate bubbling on falsy return value", () => {
26 | handlerStack.push({ keydown: () => { return handler1Called = true; } });
27 | handlerStack.push({
28 | keydown: () => {
29 | handler2Called = true;
30 | return false;
31 | }
32 | });
33 | handlerStack.bubbleEvent('keydown', {});
34 | assert.isTrue(handler2Called);
35 | assert.isFalse(handler1Called);
36 | });
37 |
38 | should("terminate bubbling on passEventToPage, and be true", () => {
39 | handlerStack.push({ keydown: () => { return handler1Called = true; } });
40 | handlerStack.push({
41 | keydown: () => {
42 | handler2Called = true;
43 | return handlerStack.passEventToPage;
44 | }
45 | });
46 | assert.isTrue(handlerStack.bubbleEvent('keydown', {}));
47 | assert.isTrue(handler2Called);
48 | assert.isFalse(handler1Called);
49 | });
50 |
51 | should("terminate bubbling on passEventToPage, and be false", () => {
52 | handlerStack.push({ keydown: () => { return handler1Called = true; } });
53 | handlerStack.push({
54 | keydown: () => {
55 | handler2Called = true;
56 | return handlerStack.suppressPropagation;
57 | }
58 | });
59 | assert.isFalse(handlerStack.bubbleEvent('keydown', {}));
60 | assert.isTrue(handler2Called);
61 | assert.isFalse(handler1Called);
62 | });
63 |
64 | should("restart bubbling on restartBubbling", () => {
65 | handler1Called = 0;
66 | handler2Called = 0;
67 | var id = handlerStack.push({
68 | keydown: () => {
69 | handler1Called++;
70 | handlerStack.remove(id);
71 | return handlerStack.restartBubbling;
72 | }
73 | });
74 | handlerStack.push({
75 | keydown: () => {
76 | handler2Called++;
77 | return true;
78 | }
79 | });
80 | assert.isTrue(handlerStack.bubbleEvent('keydown', {}));
81 | assert.isTrue(handler1Called === 1);
82 | assert.isTrue(handler2Called === 2);
83 | });
84 |
85 | should("remove handlers correctly", () => {
86 | handlerStack.push({ keydown: () => { handler1Called = true; } });
87 | const handlerId = handlerStack.push({ keydown: () => { handler2Called = true; } });
88 | handlerStack.remove(handlerId);
89 | handlerStack.bubbleEvent('keydown', {});
90 | assert.isFalse(handler2Called);
91 | assert.isTrue(handler1Called);
92 | });
93 |
94 | should("remove handlers correctly", () => {
95 | const handlerId = handlerStack.push({ keydown: () => { handler1Called = true; } });
96 | handlerStack.push({ keydown: () => { handler2Called = true; } });
97 | handlerStack.remove(handlerId);
98 | handlerStack.bubbleEvent('keydown', {});
99 | assert.isTrue(handler2Called);
100 | assert.isFalse(handler1Called);
101 | });
102 |
103 | should("handle self-removing handlers correctly", () => {
104 | handlerStack.push({ keydown: () => { handler1Called = true; } });
105 | handlerStack.push({
106 | keydown() {
107 | handler2Called = true;
108 | this.remove();
109 | return true;
110 | }
111 | });
112 | handlerStack.bubbleEvent('keydown', {});
113 | assert.isTrue(handler2Called);
114 | assert.isTrue(handler1Called);
115 | assert.equal(handlerStack.stack.length, 1);
116 | });
117 | });
118 |
--------------------------------------------------------------------------------
/tests/unit_tests/test_chrome_stubs.js:
--------------------------------------------------------------------------------
1 | //
2 | // This file contains stubs for a number of browser and chrome APIs which are missing in Deno.
3 | // The chrome.storage.sync stub does roughly what chrome.storage.sync should do, but does so synchronously.
4 | //
5 |
6 | let XMLHttpRequest;
7 |
8 | window.document = {
9 | createElement() { return {}; },
10 | addEventListener() {}
11 | };
12 |
13 | window.XMLHttpRequest =
14 | (XMLHttpRequest = class XMLHttpRequest {
15 | open() {}
16 | onload() {}
17 | send() {}
18 | });
19 |
20 | window.chrome = {
21 | areRunningVimiumTests: true,
22 |
23 | runtime: {
24 | getURL() {},
25 | getManifest() {
26 | return {version: "1.2.3"};
27 | },
28 | onConnect: {
29 | addListener() { return true; }
30 | },
31 | onMessage: {
32 | addListener() { return true; }
33 | },
34 | onInstalled: {
35 | addListener() {}
36 | }
37 | },
38 |
39 | extension: {
40 | getURL(path) { return path; },
41 | getBackgroundPage() { return {}; },
42 | getViews() { return []; }
43 | },
44 |
45 | tabs: {
46 | onUpdated: {
47 | addListener() { return true; }
48 | },
49 | onAttached: {
50 | addListener() { return true; }
51 | },
52 | onMoved: {
53 | addListener() { return true; }
54 | },
55 | onRemoved: {
56 | addListener() { return true; }
57 | },
58 | onActivated: {
59 | addListener() { return true; }
60 | },
61 | onReplaced: {
62 | addListener() { return true; }
63 | },
64 | query() { return true; }
65 | },
66 |
67 | webNavigation: {
68 | onHistoryStateUpdated: {
69 | addListener() {}
70 | },
71 | onReferenceFragmentUpdated: {
72 | addListener() {}
73 | },
74 | onCommitted: {
75 | addListener() {}
76 | }
77 | },
78 |
79 | windows: {
80 | onRemoved: {
81 | addListener() { return true; }
82 | },
83 | getAll() { return true; },
84 | onFocusChanged: {
85 | addListener() { return true; }
86 | }
87 | },
88 |
89 | browserAction: {
90 | setBadgeBackgroundColor() {}
91 | },
92 |
93 | storage: {
94 | // chrome.storage.local
95 | local: {
96 | get(_, callback) { if (callback) callback({}); },
97 | set(_, callback) { if (callback) callback({}); },
98 | remove(_, callback) { if (callback) callback({}); }
99 | },
100 |
101 | // chrome.storage.onChanged
102 | onChanged: {
103 | addListener(func) {
104 | this.func = func;
105 | },
106 |
107 | // Fake a callback from chrome.storage.sync.
108 | call(key, value) {
109 | chrome.runtime.lastError = undefined;
110 | const key_value = {};
111 | key_value[key] = { newValue: value };
112 | if (this.func) { return this.func(key_value,'sync'); }
113 | },
114 |
115 | callEmpty(key) {
116 | chrome.runtime.lastError = undefined;
117 | if (this.func) {
118 | const items = {};
119 | items[key] = {};
120 | this.func(items,'sync');
121 | }
122 | }
123 | },
124 |
125 | session: {
126 | MAX_SESSION_RESULTS: 25
127 | },
128 |
129 | // chrome.storage.sync
130 | sync: {
131 | store: {},
132 |
133 | set(items, callback) {
134 | let key, value;
135 | chrome.runtime.lastError = undefined;
136 | for (key of Object.keys(items)) {
137 | value = items[key];
138 | this.store[key] = value;
139 | }
140 | if (callback) { callback(); }
141 | // Now, generate (supposedly asynchronous) notifications for listeners.
142 | for (key of Object.keys(items)) {
143 | value = items[key];
144 | window.chrome.storage.onChanged.call(key,value);
145 | }
146 | },
147 |
148 | get(keys, callback) {
149 | let key;
150 | chrome.runtime.lastError = undefined;
151 | if (keys === null) {
152 | keys = [];
153 | for (key of Object.keys(this.store)) {
154 | const value = this.store[key];
155 | keys.push(key);
156 | }
157 | }
158 | const items = {};
159 | for (key of keys) {
160 | items[key] = this.store[key];
161 | }
162 | // Now, generate (supposedly asynchronous) callback
163 | if (callback) { return callback(items); }
164 | },
165 |
166 | remove(key, callback) {
167 | chrome.runtime.lastError = undefined;
168 | if (key in this.store) {
169 | delete this.store[key];
170 | }
171 | if (callback) { callback(); }
172 | // Now, generate (supposedly asynchronous) notification for listeners.
173 | window.chrome.storage.onChanged.callEmpty(key);
174 | }
175 | }
176 | }
177 | };
178 |
--------------------------------------------------------------------------------
/pages/vomnibar.css:
--------------------------------------------------------------------------------
1 |
2 | /* Vomnibar CSS */
3 |
4 | #vomnibar ol, #vomnibar ul {
5 | list-style: none;
6 | display: none;
7 | }
8 |
9 | #vomnibar {
10 | display: block;
11 | position: fixed;
12 | width: calc(100% - 20px); /* adjusted to keep border radius and box-shadow visible*/
13 | /*min-width: 400px;
14 | top: 70px;
15 | left: 50%;*/
16 | top: 8px;
17 | left: 8px;
18 | /*margin: 0 0 0 -40%;*/
19 | font-family: sans-serif;
20 |
21 | background: #F1F1F1;
22 | text-align: left;
23 | border-radius: 4px;
24 | box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8);
25 | border: 1px solid #aaa;
26 | z-index: 2139999999; /* One less than hint markers and the help dialog (see ../content_scripts/vimium.css). */
27 | }
28 |
29 | #vomnibar input {
30 | color: #000;
31 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
32 | font-size: 20px;
33 | height: 34px;
34 | margin-bottom: 0;
35 | padding: 4px;
36 | background-color: white;
37 | border-radius: 3px;
38 | border: 1px solid #E8E8E8;
39 | box-shadow: #444 0px 0px 1px;
40 | width: 100%;
41 | outline: none;
42 | box-sizing: border-box;
43 | }
44 |
45 | #vomnibar .vomnibarSearchArea {
46 | display: block;
47 | padding: 10px;
48 | background-color: #F1F1F1;
49 | border-radius: 4px 4px 0 0;
50 | border-bottom: 1px solid #C6C9CE;
51 | }
52 |
53 | #vomnibar ul {
54 | background-color: white;
55 | border-radius: 0 0 4px 4px;
56 | list-style: none;
57 | padding: 10px 0;
58 | padding-top: 0;
59 | }
60 |
61 | #vomnibar li {
62 | border-bottom: 1px solid #ddd;
63 | line-height: 1.1em;
64 | padding: 7px 10px;
65 | font-size: 16px;
66 | color: black;
67 | position: relative;
68 | display: list-item;
69 | margin: auto;
70 | }
71 |
72 | #vomnibar li:last-of-type {
73 | border-bottom: none;
74 | }
75 |
76 | #vomnibar li .vomnibarTopHalf, #vomnibar li .vomnibarBottomHalf {
77 | display: block;
78 | overflow: hidden;
79 | }
80 |
81 | #vomnibar li .vomnibarBottomHalf {
82 | font-size: 15px;
83 | margin-top: 3px;
84 | padding: 2px 0;
85 | }
86 |
87 | #vomnibar li .vomnibarIcon {
88 | padding: 0 13px 0 6px;
89 | vertical-align: bottom;
90 | }
91 |
92 | #vomnibar li .vomnibarSource {
93 | color: #777;
94 | margin-right: 4px;
95 | }
96 | #vomnibar li .vomnibarRelevancy {
97 | position: absolute;
98 | right: 0;
99 | top: 0;
100 | padding: 5px;
101 | background-color: white;
102 | color: black;
103 | font-family: monospace;
104 | width: 100px;
105 | overflow: hidden;
106 | }
107 |
108 | #vomnibar li .vomnibarUrl {
109 | white-space: nowrap;
110 | color: #224684;
111 | }
112 |
113 | #vomnibar li .vomnibarMatch {
114 | font-weight: bold;
115 | color: black;
116 | }
117 |
118 | #vomnibar li em, #vomnibar li .vomnibarTitle {
119 | color: black;
120 | margin-left: 4px;
121 | font-weight: normal;
122 | }
123 | #vomnibar li em { font-style: italic; }
124 | #vomnibar li em .vomnibarMatch, #vomnibar li .vomnibarTitle .vomnibarMatch {
125 | color: #333;
126 | }
127 |
128 | #vomnibar li.vomnibarSelected {
129 | background-color: #BBCEE9;
130 | font-weight: normal;
131 | }
132 |
133 | #vomnibarInput::selection {
134 | /* This is the light grey color of the vomnibar border. */
135 | /* background-color: #F1F1F1; */
136 |
137 | /* This is the light blue color of the vomnibar selected item. */
138 | /* background-color: #BBCEE9; */
139 |
140 | /* This is a considerably lighter blue than Vimium blue, which seems softer
141 | * on the eye for this purpose. */
142 | background-color: #E6EEFB;
143 | }
144 |
145 | .vomnibarInsertText {
146 | }
147 |
148 | .vomnibarNoInsertText {
149 | visibility: hidden;
150 | }
151 |
152 | /* Dark Vomnibar */
153 |
154 | @media (prefers-color-scheme: dark) {
155 | #vomnibar {
156 | border: 1px solid rgba(0, 0, 0, 0.7);
157 | border-radius: 6px;
158 | }
159 |
160 | #vomnibar .vomnibarSearchArea, #vomnibar {
161 | background-color: #35363a;
162 | border-bottom: 1px solid rgba(255, 255, 255, 0.1);
163 | }
164 |
165 | #vomnibar input {
166 | background-color: #202124;
167 | color: white;
168 | border: none;
169 | }
170 |
171 | #vomnibar ul {
172 | background-color: #202124;
173 | }
174 |
175 | #vomnibar li {
176 | border-bottom: 1px solid rgba(255, 255, 255, 0.1);
177 | }
178 |
179 | #vomnibar li.vomnibarSelected {
180 | background-color: #37383a;
181 | }
182 |
183 | #vomnibar li .vomnibarUrl {
184 | white-space: nowrap;
185 | color: #5ca1f7;
186 | }
187 |
188 | #vomnibar li em,
189 | #vomnibar li .vomnibarTitle {
190 | color: white;
191 | }
192 |
193 | #vomnibar li .vomnibarSource {
194 | color: #9aa0a6;
195 | }
196 |
197 | #vomnibar li .vomnibarMatch {
198 | color: white;
199 | }
200 |
201 | #vomnibar li em .vomnibarMatch,
202 | #vomnibar li .vomnibarTitle .vomnibarMatch {
203 | color: white;
204 | }
205 | }
206 |
--------------------------------------------------------------------------------
/content_scripts/marks.js:
--------------------------------------------------------------------------------
1 | const Marks = {
2 | previousPositionRegisters: [ "`", "'" ],
3 | localRegisters: {},
4 | currentRegistryEntry: null,
5 | mode: null,
6 |
7 | exit(continuation = null) {
8 | if (this.mode != null)
9 | this.mode.exit();
10 | this.mode = null;
11 | if (continuation)
12 | return continuation(); // TODO(philc): Is this return necessary?
13 | },
14 |
15 | // This returns the key which is used for storing mark locations in localStorage.
16 | getLocationKey(keyChar) {
17 | return `vimiumMark|${window.location.href.split('#')[0]}|${keyChar}`;
18 | },
19 |
20 | getMarkString() {
21 | return JSON.stringify({scrollX: window.scrollX, scrollY: window.scrollY, hash: window.location.hash});
22 | },
23 |
24 | setPreviousPosition() {
25 | const markString = this.getMarkString();
26 | for (const reg of this.previousPositionRegisters)
27 | this.localRegisters[reg] = markString;
28 | },
29 |
30 | showMessage(message, keyChar) {
31 | HUD.showForDuration(`${message} \"${keyChar}\".`, 1000);
32 | },
33 |
34 | // If is depressed, then it's a global mark, otherwise it's a local mark. This is consistent
35 | // vim's [A-Z] for global marks and [a-z] for local marks. However, it also admits other non-Latin
36 | // characters. The exceptions are "`" and "'", which are always considered local marks.
37 | // The "swap" command option inverts global and local marks.
38 | isGlobalMark(event, keyChar) {
39 | let shiftKey = event.shiftKey;
40 | if (this.currentRegistryEntry.options.swap)
41 | shiftKey = !shiftKey;
42 | return shiftKey && !this.previousPositionRegisters.includes(keyChar);
43 | },
44 |
45 | activateCreateMode(count, {registryEntry}) {
46 | this.currentRegistryEntry = registryEntry;
47 | this.mode = new Mode()
48 | this.mode.init({
49 | name: "create-mark",
50 | indicator: "Create mark...",
51 | exitOnEscape: true,
52 | suppressAllKeyboardEvents: true,
53 | keydown: event => {
54 | if (KeyboardUtils.isPrintable(event)) {
55 | const keyChar = KeyboardUtils.getKeyChar(event);
56 | this.exit(() => {
57 | if (this.isGlobalMark(event, keyChar)) {
58 | // We record the current scroll position, but only if this is the top frame within the tab.
59 | // Otherwise, we'll fetch the scroll position of the top frame from the background page later.
60 | let scrollX, scrollY;
61 | if (DomUtils.isTopFrame())
62 | [scrollX, scrollY] = [window.scrollX, window.scrollY];
63 | chrome.runtime.sendMessage({
64 | handler: 'createMark',
65 | markName: keyChar,
66 | scrollX,
67 | scrollY
68 | }, () => this.showMessage("Created global mark", keyChar));
69 | } else {
70 | localStorage[this.getLocationKey(keyChar)] = this.getMarkString();
71 | this.showMessage("Created local mark", keyChar);
72 | }
73 | });
74 | return handlerStack.suppressEvent;
75 | }
76 | }
77 | });
78 | },
79 |
80 | activateGotoMode(count, {registryEntry}) {
81 | this.currentRegistryEntry = registryEntry;
82 | this.mode = new Mode()
83 | this.mode.init({
84 | name: "goto-mark",
85 | indicator: "Go to mark...",
86 | exitOnEscape: true,
87 | suppressAllKeyboardEvents: true,
88 | keydown: event => {
89 | if (KeyboardUtils.isPrintable(event)) {
90 | this.exit(() => {
91 | const keyChar = KeyboardUtils.getKeyChar(event);
92 | if (this.isGlobalMark(event, keyChar)) {
93 | // This key must match @getLocationKey() in the back end.
94 | const key = `vimiumGlobalMark|${keyChar}`;
95 | Settings.storage.get(key, function(items) {
96 | if (key in items) {
97 | chrome.runtime.sendMessage({handler: 'gotoMark', markName: keyChar});
98 | HUD.showForDuration(`Jumped to global mark '${keyChar}'`, 1000);
99 | } else {
100 | HUD.showForDuration(`Global mark not set '${keyChar}'`, 1000);
101 | }
102 | });
103 | } else {
104 | const markString = this.localRegisters[keyChar] != null ? this.localRegisters[keyChar] : localStorage[this.getLocationKey(keyChar)];
105 | if (markString != null) {
106 | this.setPreviousPosition();
107 | const position = JSON.parse(markString);
108 | if (position.hash && (position.scrollX === 0) && (position.scrollY === 0))
109 | window.location.hash = position.hash;
110 | else
111 | window.scrollTo(position.scrollX, position.scrollY);
112 | this.showMessage("Jumped to local mark", keyChar);
113 | } else {
114 | this.showMessage("Local mark not set", keyChar);
115 | }
116 | }
117 | });
118 | return handlerStack.suppressEvent;
119 | }
120 | }
121 | });
122 | }
123 | };
124 |
125 | window.Marks = Marks;
126 |
--------------------------------------------------------------------------------
/lib/keyboard_utils.js:
--------------------------------------------------------------------------------
1 | let mapKeyRegistry = {};
2 | Utils.monitorChromeStorage("mapKeyRegistry", (value) => { return mapKeyRegistry = value; });
3 |
4 | const KeyboardUtils = {
5 | // This maps event.key key names to Vimium key names.
6 | keyNames: {
7 | "ArrowLeft": "left", "ArrowUp": "up", "ArrowRight": "right", "ArrowDown": "down", " ": "space",
8 | "\n": "enter" // on a keypress event of Ctrl+Enter, tested on Chrome 92 and Windows 10
9 | },
10 |
11 | init() {
12 | // TODO(philc): Remove this guard clause once Deno has a userAgent.
13 | // https://github.com/denoland/deno/issues/14362
14 | // As of 2022-04-30, Deno does not have userAgent defined on navigator.
15 | if (navigator.userAgent == null) {
16 | this.platform = "Unknown";
17 | return;
18 | }
19 | if (navigator.userAgent.indexOf("Mac") !== -1) {
20 | this.platform = "Mac";
21 | } else if (navigator.userAgent.indexOf("Linux") !== -1) {
22 | this.platform = "Linux";
23 | } else {
24 | this.platform = "Windows";
25 | }
26 | },
27 |
28 | getKeyChar(event) {
29 | let key;
30 | if (!Settings.get("ignoreKeyboardLayout")) {
31 | key = event.key;
32 | } else if (!event.code) {
33 | key = event.key != null ? event.key : ""; // Fall back to event.key (see #3099).
34 | } else if (event.code.slice(0, 6) === "Numpad") {
35 | // We cannot correctly emulate the numpad, so fall back to event.key; see #2626.
36 | key = event.key;
37 | } else {
38 | // The logic here is from the vim-like-key-notation project (https://github.com/lydell/vim-like-key-notation).
39 | key = event.code;
40 | if (key.slice(0, 3) === "Key") { key = key.slice(3); }
41 | // Translate some special keys to event.key-like strings and handle .
42 | if (this.enUsTranslations[key]) {
43 | key = event.shiftKey ? this.enUsTranslations[key][1] : this.enUsTranslations[key][0];
44 | } else if ((key.length === 1) && !event.shiftKey) {
45 | key = key.toLowerCase();
46 | }
47 | }
48 |
49 | // It appears that key is not always defined (see #2453).
50 | if (!key) {
51 | return "";
52 | } else if (key in this.keyNames) {
53 | return this.keyNames[key];
54 | } else if (this.isModifier(event)) {
55 | return ""; // Don't resolve modifier keys.
56 | } else if (key.length === 1) {
57 | return key;
58 | } else {
59 | return key.toLowerCase();
60 | }
61 | },
62 |
63 | getKeyCharString(event) {
64 | let keyChar = this.getKeyChar(event);
65 | if (!keyChar)
66 | return;
67 |
68 | const modifiers = [];
69 |
70 | if (event.shiftKey && (keyChar.length === 1)) { keyChar = keyChar.toUpperCase(); }
71 | // These must be in alphabetical order (to match the sorted modifier order in Commands.normalizeKey).
72 | if (event.altKey) { modifiers.push("a"); }
73 | if (event.ctrlKey) { modifiers.push("c"); }
74 | if (event.metaKey) { modifiers.push("m"); }
75 | if (event.shiftKey && (keyChar.length > 1)) { modifiers.push("s"); }
76 |
77 | keyChar = [...modifiers, keyChar].join("-");
78 | if (1 < keyChar.length) { keyChar = `<${keyChar}>`; }
79 | keyChar = mapKeyRegistry[keyChar] != null ? mapKeyRegistry[keyChar] : keyChar;
80 | return keyChar;
81 | },
82 |
83 | isEscape: (function() {
84 | let useVimLikeEscape = true;
85 | Utils.monitorChromeStorage("useVimLikeEscape", value => useVimLikeEscape = value);
86 |
87 | return function(event) {
88 | // is mapped to Escape in Vim by default.
89 | // Escape with a keyCode 229 means that this event comes from IME, and should not be treated as a
90 | // direct/normal Escape event. IME will handle the event, not vimium.
91 | // See https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html
92 | return ((event.key === "Escape") && (event.keyCode !== 229)) ||
93 | (useVimLikeEscape && (this.getKeyCharString(event) === ""));
94 | };
95 | })(),
96 |
97 | isBackspace(event) {
98 | return ["Backspace", "Delete"].includes(event.key);
99 | },
100 |
101 | isPrintable(event) {
102 | const s = this.getKeyCharString(event);
103 | return s && s.length == 1;
104 | },
105 |
106 | isModifier(event) {
107 | return ["Control", "Shift", "Alt", "OS", "AltGraph", "Meta"].includes(event.key);
108 | },
109 |
110 | enUsTranslations: {
111 | "Backquote": ["`", "~"],
112 | "Minus": ["-", "_"],
113 | "Equal": ["=", "+"],
114 | "Backslash": ["\\","|"],
115 | "IntlBackslash": ["\\","|"],
116 | "BracketLeft": ["[", "{"],
117 | "BracketRight": ["]", "}"],
118 | "Semicolon": [";", ":"],
119 | "Quote": ["'", '"'],
120 | "Comma": [",", "<"],
121 | "Period": [".", ">"],
122 | "Slash": ["/", "?"],
123 | "Space": [" ", " "],
124 | "Digit1": ["1", "!"],
125 | "Digit2": ["2", "@"],
126 | "Digit3": ["3", "#"],
127 | "Digit4": ["4", "$"],
128 | "Digit5": ["5", "%"],
129 | "Digit6": ["6", "^"],
130 | "Digit7": ["7", "&"],
131 | "Digit8": ["8", "*"],
132 | "Digit9": ["9", "("],
133 | "Digit0": ["0", ")"]
134 | }
135 | };
136 |
137 | KeyboardUtils.init();
138 |
139 | window.KeyboardUtils = KeyboardUtils;
140 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Vimium
2 |
3 | ## Reporting a bug
4 |
5 | File the issue [here](https://github.com/philc/vimium/issues).
6 |
7 | ## Contributing code
8 |
9 | You'd like to fix a bug or implement a feature? Great! Before getting started, understand Vimium's design
10 | principles and the goals of the maintainers.
11 |
12 | ### Vimium design principles
13 |
14 | When people first start using Vimium, it provides an incredibly powerful workflow improvement and it makes
15 | them feel awesome. Surprisingly, Vimium is applicable to a huge, broad population of people, not just users of
16 | Vim.
17 |
18 | In addition to power, a secondary goal of Vimium is approachability: minimizing the barriers which prevent a
19 | new user from feeling awesome. Many of Vimium's users haven't used Vim before -- about 1 in 5 Chrome Store
20 | reviews say this -- and most people have strong web browsing habits forged from years of browsing. Given that,
21 | it's a great experience when Vimium feels like a natural addition to Chrome which augments, but doesn't break,
22 | the user's current browsing habits.
23 |
24 | **Principles:**
25 |
26 | 1. **Easy to understand**. Even if you're not very familiar with Vim. The Vimium video shows you all you need
27 | to know to start using Vimium and feel awesome.
28 | 2. **Reliable**. The core feature set works on most sites on the web.
29 | 3. **Immediately useful**. Vimium doesn't require any configuration or doc-reading before it's useful. Just
30 | watch the video or hit `?`. You can transition into using Vimium piecemeal; you don't need to jump in
31 | whole-hog from the start.
32 | 4. **Feels native**. Vimium doesn't drastically change the way Chrome looks or behaves.
33 | 5. **Simple**. The core feature set isn't overwhelming. This principle is particularly vulnerable as we add to
34 | Vimium, so it requires our active effort to maintain this simplicity.
35 | 6. **Code simplicity**. Developers find the Vimium codebase relatively simple and easy to jump into. This
36 | provides us an active dev community.
37 |
38 | ### Which pull requests get merged?
39 |
40 | **Goals of the maintainers**
41 |
42 | The maintainers of Vimium have limited bandwidth, which influences which PRs we can review and merge.
43 |
44 | Our goals are generally to keep Vimium small, maintainable, and really nail the broad appeal use cases. This
45 | is in contrast to adding and maintaining an increasing number of complex or niche features. We recommend those
46 | live in forked repos rather than the mainline Vimium repo.
47 |
48 | PRs we'll likely merge:
49 |
50 | * Reflect all of the Vimium design principles.
51 | * Are useful for lots of Vimium users.
52 | * Have simple implementations (straightforward code, few lines of code).
53 |
54 | PRs we likely won't:
55 |
56 | * Violate one or more of our design principles.
57 | * Are niche.
58 | * Have complex implementations -- more code than they're worth.
59 |
60 | Tips for preparing a PR:
61 |
62 | * If you want to check with us first before implementing something big, open an issue proposing the idea.
63 | You'll get feedback from the maintainers as to whether it's something we'll likely merge.
64 | * Try to keep PRs around 50 LOC or less. Bigger PRs create inertia for review.
65 |
66 | ### Installing From Source
67 |
68 | Vimium is written in Javascript. To install Vimium from source:
69 |
70 | **On Chrome/Chromium:**
71 |
72 | 1. Navigate to `chrome://extensions`
73 | 1. Toggle into Developer Mode
74 | 1. Click on "Load Unpacked Extension..."
75 | 1. Select the Vimium directory you've cloned from Github.
76 |
77 | **On Firefox:**
78 |
79 | For 'local storage' to work while using the temporary addon, you need to add an 'application' section to the
80 | manifest with an arbitrary ID that is unique for you, for example:
81 |
82 | "applications": {
83 | "gecko": {
84 | "id": "vimium@example.net"
85 | }
86 | },
87 |
88 | After that:
89 |
90 | 1. Open Firefox
91 | 1. Enter "about:debugging" in the URL bar
92 | 1. Click "Load Temporary Add-on"
93 | 1. Open the Vimium directory you've cloned from Github, and select any file inside.
94 |
95 | ### Running the tests
96 |
97 | Our tests use [shoulda.js](https://github.com/philc/shoulda.js) and
98 | [Puppeteer](https://github.com/puppeteer/puppeteer). To run the tests:
99 |
100 | 1. Install [Deno](https://deno.land/) if you don't have it already.
101 | 1. `PUPPETEER_PRODUCT=chrome deno run -A --unstable https://deno.land/x/puppeteer@9.0.2/install.ts` to
102 | install [Puppeteer](https://github.com/lucacasonato/deno-puppeteer)
103 | 1. `./make.js test` to build the code and run the tests.
104 |
105 | ### Coding Style
106 |
107 | * We follow the recommendations from the [Airbnb Javascript style guide](https://github.com/airbnb/javascript).
108 | * When writing comments, uppercase the first letter of your sentence, and put a period at the end.
109 | * We follow two major differences from this style guide:
110 | * Wrap lines at 110 characters instead of 100, for historical reasons.
111 | * We use double-quoted strings by default, for historical reasons.
112 | * We allow short, simple if statements to be used without braces, like so:
113 |
114 | if (string.length == 0)
115 | return;
116 |
117 | ...
118 | * We're currently using Javascript language features from ES2018 or earlier. If we desire to use something
119 | introduced in a later version of Javascript, we need to remember to update the minimum Chrome and Firefox
120 | versions required.
121 |
--------------------------------------------------------------------------------
/tests/dom_tests/dom_utils_test.js:
--------------------------------------------------------------------------------
1 | context("DOM content loaded", () => {
2 |
3 | // The DOM content has already loaded, this should be called immediately.
4 | should("call callback immediately.", () => {
5 | let called = false;
6 | DomUtils.documentReady(() => called = true);
7 | assert.isTrue(called);
8 | }),
9 |
10 | // See ./dom_tests.html; the callback there was installed before the document was ready.
11 | should("already have called callback embedded in test page.",
12 | () => assert.isTrue((window.documentReadyListenerCalled != null) && window.documentReadyListenerCalled))
13 | });
14 |
15 | context("Check visibility", () => {
16 |
17 | should("detect visible elements as visible", () => {
18 | document.getElementById("test-div").innerHTML = `\
19 | test
\
20 | `;
21 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null);
22 | });
23 |
24 | should("detect display:none links as hidden", () => {
25 | document.getElementById("test-div").innerHTML = `\
26 | test \
27 | `;
28 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true)));
29 | });
30 |
31 | should("detect visibility:hidden links as hidden", () => {
32 | document.getElementById("test-div").innerHTML = `\
33 | test \
34 | `;
35 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true)));
36 | });
37 |
38 | should("detect elements nested in display:none elements as hidden", () => {
39 | document.getElementById("test-div").innerHTML = `\
40 | \
43 | `;
44 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true)));
45 | });
46 |
47 | should("detect links nested in visibility:hidden elements as hidden", () => {
48 | document.getElementById("test-div").innerHTML = `\
49 | \
52 | `;
53 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true)));
54 | });
55 |
56 | should("detect links outside viewport as hidden", () => {
57 | document.getElementById("test-div").innerHTML = `\
58 | test
59 | test \
60 | `;
61 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true)));
62 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('bar')), true)));
63 | });
64 |
65 | should("detect links only partially outside viewport as visible", () => {
66 | document.getElementById("test-div").innerHTML = `\
67 | test
68 | test \
69 | `;
70 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null);
71 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('bar')), true)) !== null);
72 | });
73 |
74 | should("detect links that contain only floated / absolutely-positioned divs as visible", () => {
75 | document.getElementById("test-div").innerHTML = `\
76 |
77 | test
78 | \
79 | `;
80 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null);
81 |
82 | document.getElementById("test-div").innerHTML = `\
83 |
84 | test
85 | \
86 | `;
87 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null);
88 | });
89 |
90 | should("detect links that contain only invisible floated divs as invisible", () => {
91 | document.getElementById("test-div").innerHTML = `\
92 |
93 | test
94 | \
95 | `;
96 | assert.equal(null, (DomUtils.getVisibleClientRect((document.getElementById('foo')), true)));
97 | });
98 |
99 | should("detect font-size: 0; and display: inline; links when their children are display: inline", () => {
100 | // This test represents the minimal test case covering issue #1554.
101 | document.getElementById("test-div").innerHTML = `\
102 |
103 | test
104 | \
105 | `;
106 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null);
107 | });
108 |
109 | should("detect links inside opacity:0 elements as visible", () => {
110 | // XXX This is an expected failure. See issue #16.
111 | document.getElementById("test-div").innerHTML = `\
112 | \
115 | `;
116 | assert.isTrue((DomUtils.getVisibleClientRect((document.getElementById('foo')), true)) !== null);
117 | })
118 | });
119 |
120 | // NOTE(philc): This test doesn't pass on puppeteer. It's unclear from the XXX comment if it's supposed to.
121 | // should("Detect links within SVGs as visible"), () => {
122 | // # XXX this is an expected failure
123 | // document.getElementById("test-div").innerHTML = """
124 | //
125 | //
126 | // test
127 | //
128 | //
129 | // """
130 | // assert.equal(null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true));
131 | // }
132 |
--------------------------------------------------------------------------------
/content_scripts/mode_key_handler.js:
--------------------------------------------------------------------------------
1 | // Example key mapping (@keyMapping):
2 | // i:
3 | // command: "enterInsertMode", ... # This is a registryEntry object (as too are the other commands).
4 | // g:
5 | // g:
6 | // command: "scrollToTop", ...
7 | // t:
8 | // command: "nextTab", ...
9 | //
10 | // This key-mapping structure is generated by Commands.generateKeyStateMapping() and may be arbitrarily deep.
11 | // Observe that @keyMapping["g"] is itself also a valid key mapping. At any point, the key state (@keyState)
12 | // consists of a (non-empty) list of such mappings.
13 |
14 | class KeyHandlerMode extends Mode {
15 | setKeyMapping(keyMapping) { this.keyMapping = keyMapping; this.reset(); }
16 | setPassKeys(passKeys) { this.passKeys = passKeys; this.reset(); }
17 |
18 | // Only for tests.
19 | setCommandHandler(commandHandler) {
20 | this.commandHandler = commandHandler;
21 | }
22 |
23 | // Reset the key state, optionally retaining the count provided.
24 | reset(countPrefix) {
25 | if (countPrefix == null) { countPrefix = 0; }
26 | this.countPrefix = countPrefix;
27 | this.keyState = [this.keyMapping];
28 | }
29 |
30 | init(options) {
31 | const args = Object.assign(options, {keydown: this.onKeydown.bind(this)});
32 | super.init(args);
33 |
34 | this.commandHandler = options.commandHandler || (function() {});
35 | this.setKeyMapping(options.keyMapping || {});
36 |
37 | if (options.exitOnEscape) {
38 | // If we're part way through a command's key sequence, then a first Escape should reset the key state,
39 | // and only a second Escape should actually exit this mode.
40 | this.push({
41 | _name: "key-handler-escape-listener",
42 | keydown: event => {
43 | if (KeyboardUtils.isEscape(event) && !this.isInResetState()) {
44 | this.reset();
45 | return this.suppressEvent;
46 | } else {
47 | return this.continueBubbling;
48 | }
49 | }
50 | });
51 | }
52 | }
53 |
54 | onKeydown(event) {
55 | const keyChar = KeyboardUtils.getKeyCharString(event);
56 | const isEscape = KeyboardUtils.isEscape(event);
57 | if (isEscape && ((this.countPrefix !== 0) || (this.keyState.length !== 1))) {
58 | return DomUtils.consumeKeyup(event, () => this.reset());
59 | // If the help dialog loses the focus, then Escape should hide it; see point 2 in #2045.
60 | } else if (isEscape && HelpDialog && HelpDialog.isShowing()) {
61 | HelpDialog.toggle();
62 | return this.suppressEvent;
63 | } else if (isEscape) {
64 | return this.continueBubbling;
65 | } else if (this.isMappedKey(keyChar)) {
66 | this.handleKeyChar(keyChar);
67 | return this.suppressEvent;
68 | } else if (this.isCountKey(keyChar)) {
69 | const digit = parseInt(keyChar);
70 | this.reset(this.keyState.length === 1 ? (this.countPrefix * 10) + digit : digit);
71 | return this.suppressEvent;
72 | } else {
73 | if (keyChar) { this.reset(); }
74 | return this.continueBubbling;
75 | }
76 | }
77 |
78 | // This tests whether there is a mapping of keyChar in the current key state (and accounts for pass keys).
79 | isMappedKey(keyChar) {
80 | // TODO(philc): tweak the generated js.
81 | return ((this.keyState.filter((mapping) => keyChar in mapping))[0] != null) && !this.isPassKey(keyChar);
82 | }
83 |
84 | // This tests whether keyChar is a digit (and accounts for pass keys).
85 | isCountKey(keyChar) {
86 | return keyChar
87 | && ((this.countPrefix > 0 ? '0' : '1') <= keyChar && keyChar <= '9')
88 | && !this.isPassKey(keyChar);
89 | }
90 |
91 | // Keystrokes are *never* considered pass keys if the user has begun entering a command. So, for example, if
92 | // 't' is a passKey, then the "t"-s of 'gt' and '99t' are neverthless handled as regular keys.
93 | isPassKey(keyChar) {
94 | // Find all *continuation* mappings for keyChar in the current key state (i.e. not the full key mapping).
95 | const mappings = (this.keyState.filter((mapping) => keyChar in mapping && (mapping !== this.keyMapping)));
96 | // If there are no continuation mappings, and there's no count prefix, and keyChar is a pass key, then
97 | // it's a pass key.
98 | return mappings.length == 0
99 | && this.countPrefix == 0
100 | && this.passKeys
101 | && this.passKeys.includes(keyChar);
102 | }
103 |
104 | isInResetState() {
105 | return (this.countPrefix === 0) && (this.keyState.length === 1);
106 | }
107 |
108 | handleKeyChar(keyChar) {
109 | bgLog(`handle key ${keyChar} (${this.name})`);
110 | // A count prefix applies only so long a keyChar is mapped in @keyState[0]; e.g. 7gj should be 1j.
111 | if (!(keyChar in this.keyState[0]))
112 | this.countPrefix = 0;
113 |
114 | // Advance the key state. The new key state is the current mappings of keyChar, plus @keyMapping.
115 | const state = (this.keyState.filter((mapping) => keyChar in mapping).map((mapping) => mapping[keyChar]));
116 | state.push(this.keyMapping);
117 | this.keyState = state;
118 |
119 | if (this.keyState[0].command != null) {
120 | const command = this.keyState[0];
121 | const count = this.countPrefix > 0 ? this.countPrefix : 1;
122 | bgLog(` invoke ${command.command} count=${count} `);
123 | this.reset();
124 | this.commandHandler({command, count});
125 | if ((this.options.count != null) && (--this.options.count <= 0))
126 | this.exit();
127 | }
128 | return this.suppressEvent;
129 | }
130 | }
131 |
132 | window.KeyHandlerMode = KeyHandlerMode;
133 |
--------------------------------------------------------------------------------
/tests/unit_tests/commands_test.js:
--------------------------------------------------------------------------------
1 | import "./test_helper.js";
2 | import "../../background_scripts/bg_utils.js";
3 | import "../../lib/settings.js";
4 | import "../../lib/keyboard_utils.js";
5 | import "../../background_scripts/commands.js";
6 | import "../../content_scripts/mode.js";
7 | import "../../content_scripts/mode_key_handler.js";
8 | // Include mode_normal to check that all commands have been implemented.
9 | import "../../content_scripts/mode_normal.js";
10 | import "../../content_scripts/link_hints.js";
11 | import "../../content_scripts/marks.js";
12 | import "../../content_scripts/vomnibar.js";
13 |
14 | context("Key mappings", () => {
15 | const testKeySequence = (key, expectedKeyText, expectedKeyLength) => {
16 | const keySequence = Commands.parseKeySequence(key);
17 | assert.equal(expectedKeyText, keySequence.join("/"));
18 | assert.equal(expectedKeyLength, keySequence.length);
19 | };
20 |
21 | should("lowercase keys correctly", () => {
22 | testKeySequence("a", "a", 1);
23 | testKeySequence("A", "A", 1);
24 | testKeySequence("ab", "a/b", 2);
25 | });
26 |
27 | should("recognise non-alphabetic keys", () => {
28 | testKeySequence("#", "#", 1);
29 | testKeySequence(".", ".", 1);
30 | testKeySequence("##", "#/#", 2);
31 | testKeySequence("..", "./.", 2);
32 | });
33 |
34 | should("parse keys with modifiers", () => {
35 | testKeySequence("", "", 1);
36 | testKeySequence("", "", 1);
37 | testKeySequence("", "", 1);
38 | testKeySequence("", "/", 2);
39 | testKeySequence("", "", 1);
40 | testKeySequence("z", "z/", 2);
41 | });
42 |
43 | should("normalize with modifiers", () => {
44 | // Modifiers should be in alphabetical order.
45 | testKeySequence("", "", 1);
46 | });
47 |
48 | should("parse and normalize named keys", () => {
49 | testKeySequence("", "", 1);
50 | testKeySequence("", "", 1);
51 | testKeySequence("", "", 1);
52 | testKeySequence("", "", 1);
53 | testKeySequence("", "", 1);
54 | });
55 |
56 | should("handle angle brackets which are part of not modifiers", () => {
57 | testKeySequence("<", "<", 1);
58 | testKeySequence(">", ">", 1);
59 |
60 | testKeySequence("<<", "<", 2);
61 | testKeySequence(">>", ">/>", 2);
62 |
63 | testKeySequence("<>", ">", 2);
64 | testKeySequence("<>", ">", 2);
65 |
66 | testKeySequence("<", "", 2);
67 | testKeySequence(">", ">", 1);
68 |
69 | testKeySequence("", " ", 3);
70 | });
71 |
72 | should("negative tests", () => {
73 | // These should not be parsed as modifiers.
74 | testKeySequence("", "", 5);
75 | testKeySequence("", "", 6);
76 | });
77 | });
78 |
79 |
80 | context("Validate commands and options", () => {
81 | // TODO(smblott) For this and each following test, is there a way to structure the tests such that the name
82 | // of the offending command appears in the output, if the test fails?
83 | should("have either noRepeat or repeatLimit, but not both", () => {
84 | for (let command of Object.keys(Commands.availableCommands)) {
85 | const options = Commands.availableCommands[command];
86 | assert.isTrue(!(options.noRepeat && options.repeatLimit));
87 | }
88 | });
89 |
90 | should("describe each command", () => {
91 | for (let command of Object.keys(Commands.availableCommands)) {
92 | const options = Commands.availableCommands[command];
93 | assert.equal("string", typeof options.description);
94 | }
95 | });
96 |
97 | should("define each command in each command group", () => {
98 | for (let group of Object.keys(Commands.commandGroups)) {
99 | const commands = Commands.commandGroups[group];
100 | for (let command of commands) {
101 | assert.equal("string", typeof command);
102 | assert.isTrue(Commands.availableCommands[command]);
103 | }
104 | }
105 | });
106 |
107 | should("have valid commands for each advanced command", () => {
108 | for (let command of Commands.advancedCommands) {
109 | assert.equal("string", typeof command);
110 | assert.isTrue(Commands.availableCommands[command]);
111 | }
112 | });
113 |
114 | should("have valid commands for each default key mapping", () => {
115 | const count = Object.keys(Commands.keyToCommandRegistry).length;
116 | assert.isTrue((0 < count));
117 | for (let key of Object.keys(Commands.keyToCommandRegistry)) {
118 | const command = Commands.keyToCommandRegistry[key];
119 | assert.equal("object", typeof command);
120 | assert.isTrue(Commands.availableCommands[command.command]);
121 | }
122 | })
123 | });
124 |
125 | context("Validate advanced commands", () => {
126 | should("include each advanced command in a command group", () => {
127 | let allCommands = Object.keys(Commands.commandGroups).map((k) => Commands.commandGroups[k]).flat(1);
128 | for (let command of Commands.advancedCommands)
129 | assert.isTrue(allCommands.includes(command));
130 | })
131 | });
132 |
133 | context("Parse commands", () => {
134 | should("omit whitespace", () => {
135 | assert.equal(0, BgUtils.parseLines(" \n \n ").length);
136 | });
137 |
138 | should("omit comments", () => {
139 | assert.equal(0, BgUtils.parseLines(" # comment \n \" comment \n ").length);
140 | });
141 |
142 | should("join lines", () => {
143 | assert.equal(1, BgUtils.parseLines("a\\\nb").length);
144 | assert.equal("ab", BgUtils.parseLines("a\\\nb")[0]);
145 | });
146 |
147 | should("trim lines", () => {
148 | assert.equal(2, BgUtils.parseLines(" a \n b").length);
149 | assert.equal("a", BgUtils.parseLines(" a \n b")[0]);
150 | assert.equal("b", BgUtils.parseLines(" a \n b")[1]);
151 | })
152 | });
153 |
--------------------------------------------------------------------------------
/pages/options.css:
--------------------------------------------------------------------------------
1 | /* NOTE: This stylesheet is included in both options.html and popup.html. So changes here affect
2 | both of these. */
3 | body {
4 | font: 14px "DejaVu Sans", "Arial", sans-serif;
5 | color: #303942;
6 | margin: 0 auto;
7 | }
8 | a, a:visited { color: #15c; }
9 | a:active { color: #052577; }
10 | div#wrapper, #footerWrapper {
11 | width: 540px;
12 | margin-left: 35px;
13 | }
14 | header {
15 | font-size: 18px;
16 | font-weight: normal;
17 | border-bottom: 1px solid #eee;
18 | padding: 20px 0 15px 0;
19 | width: 100%;
20 | }
21 | button {
22 | -webkit-user-select: none;
23 | -webkit-appearance: none;
24 | background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);
25 | border: 1px solid rgba(0, 0, 0, 0.25);
26 | border-radius: 2px;
27 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);
28 | color: #444;
29 | font: inherit;
30 | text-shadow: 0 1px 0 #f0f0f0;
31 | height: 24px;
32 | font-size: 12px;
33 | padding: 0 10px;
34 | }
35 | button:hover {
36 | background-image: -webkit-linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0);
37 | border-color: rgba(0, 0, 0, 0.3);
38 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12), inset 0 1px 2px rgba(255, 255, 255, 0.95);
39 | color: black;
40 | }
41 | button:active {
42 | background-image: -webkit-linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7);
43 | box-shadow: none;
44 | text-shadow: none;
45 | }
46 | button[disabled], button[disabled]:hover, button[disabled]:active {
47 | background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);
48 | border: 1px solid rgba(0, 0, 0, 0.25);
49 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);
50 | text-shadow: 0 1px 0 #f0f0f0;
51 | color: #888;
52 | }
53 | input[type="checkbox"] {
54 | -webkit-user-select: none;
55 | }
56 | label:hover {
57 | color: black;
58 | }
59 | pre, code, .code {
60 | font-family: Consolas, "Liberation Mono", Courier, monospace;
61 | }
62 | pre {
63 | margin: 5px;
64 | border-left: 1px solid #eee;
65 | padding-left: 5px;
66 |
67 | }
68 | input, textarea {
69 | box-sizing: border-box;
70 | }
71 | textarea {
72 | /* Horizontal resizing is pretty screwy-looking. */
73 | resize: vertical;
74 | }
75 | table#options{
76 | width: 100%;
77 | font-size: 14px;
78 | position: relative;
79 | border-spacing: 0 23px;
80 | }
81 | .example {
82 | font-size: 12px;
83 | line-height: 16px;
84 | color: #979ca0;
85 | margin-left: 20px;
86 | }
87 | .info {
88 | margin-left: 0px;
89 | }
90 | .caption {
91 | margin-right: 10px;
92 | min-width: 130px;
93 | padding-top: 3px;
94 | vertical-align: top;
95 | }
96 | td { padding: 0; }
97 | div#exampleKeyMapping {
98 | margin-left: 10px;
99 | margin-top: 5px;
100 | }
101 | input#linkHintCharacters {
102 | width: 100%;
103 | }
104 | input#linkHintNumbers {
105 | width: 100%;
106 | }
107 | input#linkHintCharacters {
108 | width: 100%;
109 | }
110 | input#scrollStepSize {
111 | width: 50px;
112 | margin-right: 3px;
113 | padding-left: 3px;
114 | }
115 | textarea#userDefinedLinkHintCss, textarea#keyMappings, textarea#searchEngines {
116 | width: 100%;;
117 | min-height: 140px;
118 | white-space: pre;
119 | }
120 | input#previousPatterns, input#nextPatterns {
121 | width: 100%;
122 | }
123 | input#newTabUrl {
124 | width: 100%;
125 | }
126 | input#searchUrl {
127 | width: 100%;
128 | }
129 | #status {
130 | margin-left: 10px;
131 | font-size: 80%;
132 | }
133 | /* Make the caption in the settings table as small as possible, to pull the other fields to the right. */
134 | .caption {
135 | width: 1px;
136 | white-space: nowrap;
137 | }
138 | #buttonsPanel { width: 100%; }
139 | #advancedOptions { display: none; }
140 | #advancedOptionsButton { width: 170px; }
141 | .help {
142 | position: absolute;
143 | right: -320px;
144 | width: 320px;
145 | }
146 | input[type=text]:read-only, input[type=number]:read-only, textarea:read-only {
147 | background-color: #eee;
148 | color: #666;
149 | pointer-events: none;
150 | -webkit-user-select: none;
151 | }
152 | input[type="text"], textarea {
153 | border: 1px solid #bfbfbf;
154 | border-radius: 2px;
155 | color: #444;
156 | background-color: white;
157 | font: inherit;
158 | padding: 3px;
159 | }
160 | button:focus, input[type="text"]:focus, textarea:focus {
161 | -webkit-transition: border-color 200ms;
162 | border-color: #4d90fe;
163 | outline: none;
164 | }
165 | /* Boolean options have a tighter form representation than text options. */
166 | td.booleanOption { font-size: 12px; }
167 | /* Ids and classes for rendering exclusionRules */
168 | #exclusionScrollBox {
169 | overflow: scroll;
170 | overflow-x: hidden;
171 | overflow-y: auto;
172 | /* Each exclusion rule is about 30px, so this allows 7 before scrolling */
173 | max-height: 215px;
174 | min-height: 75px;
175 | border-radius: 2px;
176 | color: #444;
177 | width: 100%
178 | }
179 | #exclusionRules {
180 | width: 100%;
181 | }
182 | .exclusionRulePassKeys {
183 | width: 33%;
184 | }
185 | .exclusionRemove {
186 | width: 1px; /* 1px; smaller than the button itself. */
187 | }
188 | .exclusionRemoveButton {
189 | border: none;
190 | background-color: #fff;
191 | color: #979ca0;
192 | }
193 | .exclusionRemoveButton:hover {
194 | color: #444;
195 | }
196 | input.pattern, input.passKeys, .exclusionHeaderText {
197 | width: 100%;
198 | font-family: Consolas, "Liberation Mono", Courier, monospace;
199 | font-size: 14px;
200 | }
201 | .exclusionHeaderText {
202 | padding-left: 3px;
203 | color: #979ca0;
204 | }
205 | #exclusionAddButton {
206 | float: right;
207 | margin-right: 0px;
208 | margin-top: 5px;
209 | }
210 | #footer {
211 | background: #f5f5f5;
212 | border-top: 1px solid #979ca0;
213 | position: fixed;
214 | bottom: 0px;
215 | z-index: 10;
216 | }
217 | #footer, #footerTable, #footerTableData {
218 | width: 100%;
219 | }
220 | #endSpace {
221 | /* Leave space for the fixed footer. */
222 | min-height: 30px;
223 | max-height: 30px;
224 | }
225 | #helpText, #versionAndOptions {
226 | font-size: 12px;
227 | }
228 | #saveOptionsTableData {
229 | float: right;
230 | }
231 | #saveOptions, #exclusionAddButton {
232 | white-space: nowrap;
233 | width: 110px;
234 | }
235 | #backupLink {
236 | cursor: pointer;
237 | }
238 |
--------------------------------------------------------------------------------
/background_scripts/marks.js:
--------------------------------------------------------------------------------
1 | const Marks = {
2 | // This returns the key which is used for storing mark locations in chrome.storage.sync.
3 | getLocationKey(markName) { return `vimiumGlobalMark|${markName}`; },
4 |
5 | // Get the part of a URL we use for matching here (that is, everything up to the first anchor).
6 | getBaseUrl(url) { return url.split("#")[0]; },
7 |
8 | // Create a global mark. We record vimiumSecret with the mark so that we can tell later, when the mark is
9 | // used, whether this is the original Vimium session or a subsequent session. This affects whether or not
10 | // tabId can be considered valid.
11 | create(req, sender) {
12 | chrome.storage.local.get("vimiumSecret", items => {
13 | const markInfo = {
14 | vimiumSecret: items.vimiumSecret,
15 | markName: req.markName,
16 | url: this.getBaseUrl(sender.tab.url),
17 | tabId: sender.tab.id,
18 | scrollX: req.scrollX,
19 | scrollY: req.scrollY
20 | };
21 |
22 | if ((markInfo.scrollX != null) && (markInfo.scrollY != null)) {
23 | return this.saveMark(markInfo);
24 | } else {
25 | // The front-end frame hasn't provided the scroll position (because it's not the top frame within its
26 | // tab). We need to ask the top frame what its scroll position is.
27 | return chrome.tabs.sendMessage(sender.tab.id, {name: "getScrollPosition"}, response => {
28 | return this.saveMark(Object.assign(markInfo,
29 | {scrollX: response.scrollX, scrollY: response.scrollY}));
30 | });
31 | }
32 | });
33 | },
34 |
35 | saveMark(markInfo) {
36 | const item = {};
37 | item[this.getLocationKey(markInfo.markName)] = markInfo;
38 | return Settings.storage.set(item);
39 | },
40 |
41 | // Goto a global mark. We try to find the original tab. If we can't find that, then we try to find another
42 | // tab with the original URL, and use that. And if we can't find such an existing tab, then we create a new
43 | // one. Whichever of those we do, we then set the scroll position to the original scroll position.
44 | goto(req, sender) {
45 | chrome.storage.local.get("vimiumSecret", items => {
46 | const {
47 | vimiumSecret
48 | } = items;
49 | const key = this.getLocationKey(req.markName);
50 | return Settings.storage.get(key, items => {
51 | const markInfo = items[key];
52 | if (markInfo.vimiumSecret !== vimiumSecret) {
53 | // This is a different Vimium instantiation, so markInfo.tabId is definitely out of date.
54 | return this.focusOrLaunch(markInfo, req);
55 | } else {
56 | // Check whether markInfo.tabId still exists. According to
57 | // https://developer.chrome.com/extensions/tabs, tab Ids are unqiue within a Chrome session. So, if
58 | // we find a match, we can use it.
59 | return chrome.tabs.get(markInfo.tabId, tab => {
60 | if (!chrome.runtime.lastError && tab && tab.url && (markInfo.url === this.getBaseUrl(tab.url))) {
61 | // The original tab still exists.
62 | return this.gotoPositionInTab(markInfo);
63 | } else {
64 | // The original tab no longer exists.
65 | return this.focusOrLaunch(markInfo, req);
66 | }
67 | });
68 | }
69 | });
70 | });
71 | },
72 |
73 | // Focus an existing tab and scroll to the given position within it.
74 | gotoPositionInTab({ tabId, scrollX, scrollY }) {
75 | chrome.tabs.update(tabId, { active: true }, (tab) => {
76 | chrome.windows.update(tab.windowId, { focused: true });
77 | chrome.tabs.sendMessage(tabId, {name: "setScrollPosition", scrollX, scrollY});
78 | });
79 | },
80 |
81 | // The tab we're trying to find no longer exists. We either find another tab with a matching URL and use it,
82 | // or we create a new tab.
83 | focusOrLaunch(markInfo, req) {
84 | // If we're not going to be scrolling to a particular position in the tab, then we choose all tabs with a
85 | // matching URL prefix. Otherwise, we require an exact match (because it doesn't make sense to scroll
86 | // unless there's an exact URL match).
87 | const query = markInfo.scrollX === markInfo.scrollY && markInfo.scrollY === 0 ? `${markInfo.url}*` : markInfo.url;
88 | return chrome.tabs.query({ url: query }, tabs => {
89 | if (tabs.length > 0) {
90 | // We have at least one matching tab. Pick one and go to it.
91 | return this.pickTab(tabs, tab => {
92 | return this.gotoPositionInTab(Object.assign(markInfo, {tabId: tab.id}));
93 | });
94 | } else {
95 | // There is no existing matching tab, we'll have to create one.
96 | return TabOperations.openUrlInNewTab(Object.assign(req, {url: this.getBaseUrl(markInfo.url)}), tab => {
97 | // Note. tabLoadedHandlers is defined in "main.js". The handler below will be called when the tab
98 | // is loaded, its DOM is ready and it registers with the background page.
99 | return tabLoadedHandlers[tab.id] =
100 | () => this.gotoPositionInTab(Object.assign(markInfo, {tabId: tab.id}));
101 | });
102 | }
103 | });
104 | },
105 |
106 | // Given a list of tabs candidate tabs, pick one. Prefer tabs in the current window and tabs with shorter
107 | // (matching) URLs.
108 | pickTab(tabs, callback) {
109 | const tabPicker = function({ id }) {
110 | // Prefer tabs in the current window, if there are any.
111 | let tab;
112 | const tabsInWindow = tabs.filter(tab => tab.windowId === id);
113 | if (tabsInWindow.length > 0) { tabs = tabsInWindow; }
114 | // If more than one tab remains and the current tab is still a candidate, then don't pick the current
115 | // tab (because jumping to it does nothing).
116 | if (tabs.length > 1)
117 | tabs = tabs.filter(t => !t.active)
118 |
119 | // Prefer shorter URLs.
120 | tabs.sort((a, b) => a.url.length - b.url.length);
121 | return callback(tabs[0]);
122 | };
123 | if (chrome.windows != null)
124 | return chrome.windows.getCurrent(tabPicker);
125 | else
126 | return tabPicker({id: undefined});
127 | }
128 | };
129 |
130 | window.Marks = Marks;
131 |
--------------------------------------------------------------------------------
/pages/help_dialog.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Vimium Help
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
35 |
36 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | Vim ium
45 |
46 |
47 | Options
48 | Wiki
49 | ×
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | Navigating the page
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | Using the vomnibar
68 |
69 |
70 |
71 | Using find
72 |
73 |
74 |
75 | Navigating history
76 |
77 |
78 |
79 | Manipulating tabs
80 |
81 |
82 |
83 | Miscellaneous
84 |
85 |
86 |
87 |
88 |
89 |
101 |
102 |
103 |
104 |
105 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 | ,
137 |
138 |
139 | ( )
140 |
141 |
142 |
143 |
--------------------------------------------------------------------------------
/lib/handler_stack.js:
--------------------------------------------------------------------------------
1 | class HandlerStack {
2 | constructor() {
3 | this.debug = false;
4 | this.eventNumber = 0;
5 | this.stack = [];
6 | this.counter = 0;
7 |
8 | // A handler should return this value to immediately discontinue bubbling and pass the event on to the
9 | // underlying page.
10 | this.passEventToPage = new Object();
11 |
12 | // A handler should return this value to indicate that the event has been consumed, and no further
13 | // processing should take place. The event does not propagate to the underlying page.
14 | this.suppressPropagation = new Object();
15 |
16 | // A handler should return this value to indicate that bubbling should be restarted. Typically, this is
17 | // used when, while bubbling an event, a new mode is pushed onto the stack.
18 | this.restartBubbling = new Object();
19 |
20 | // A handler should return this value to continue bubbling the event.
21 | this.continueBubbling = true;
22 |
23 | // A handler should return this value to suppress an event.
24 | this.suppressEvent = false;
25 | }
26 |
27 | // Adds a handler to the top of the stack. Returns a unique ID for that handler that can be used to remove it
28 | // later.
29 | push(handler) {
30 | if (!handler._name) { handler._name = `anon-${this.counter}`; }
31 | this.stack.push(handler);
32 | return handler.id = ++this.counter;
33 | }
34 |
35 | // As above, except the new handler is added to the bottom of the stack.
36 | unshift(handler) {
37 | if (!handler._name) { handler._name = `anon-${this.counter}`; }
38 | handler._name += "/unshift";
39 | this.stack.unshift(handler);
40 | return handler.id = ++this.counter;
41 | }
42 |
43 | // Called whenever we receive a key or other event. Each individual handler has the option to stop the
44 | // event's propagation by returning a falsy value, or stop bubbling by returning @suppressPropagation or
45 | // @passEventToPage.
46 | bubbleEvent(type, event) {
47 | this.eventNumber += 1;
48 | const {
49 | eventNumber
50 | } = this;
51 | for (let handler of this.stack.slice().reverse()) {
52 | // A handler might have been removed (handler.id == null), so check; or there might just be no handler
53 | // for this type of event.
54 | if (!(handler != null ? handler.id : undefined) || !handler[type]) {
55 | if (this.debug) { this.logResult(eventNumber, type, event, handler, `skip [${(handler[type] != null)}]`); }
56 | } else {
57 | this.currentId = handler.id;
58 | const result = handler[type].call(this, event);
59 | if (this.debug) { this.logResult(eventNumber, type, event, handler, result); }
60 | if (result === this.passEventToPage) {
61 | return true;
62 | } else if (result === this.suppressPropagation) {
63 | if (type === "keydown") {
64 | DomUtils.consumeKeyup(event, null, true);
65 | } else {
66 | DomUtils.suppressPropagation(event);
67 | }
68 | return false;
69 | } else if (result === this.restartBubbling) {
70 | return this.bubbleEvent(type, event);
71 | } else if ((result === this.continueBubbling) || (result && (result !== this.suppressEvent))) {
72 | true; // Do nothing, but continue bubbling.
73 | } else {
74 | // result is @suppressEvent or falsy.
75 | if (this.isChromeEvent(event)) {
76 | if (type === "keydown") {
77 | DomUtils.consumeKeyup(event);
78 | } else {
79 | DomUtils.suppressEvent(event);
80 | }
81 | }
82 | return false;
83 | }
84 | }
85 | }
86 |
87 | // None of our handlers care about this event, so pass it to the page.
88 | return true;
89 | }
90 |
91 | remove(id) {
92 | if (id == null) { id = this.currentId; }
93 | for (let i = this.stack.length - 1; i >= 0; i--) {
94 | const handler = this.stack[i];
95 | if (handler.id === id) {
96 | // Mark the handler as removed.
97 | handler.id = null;
98 | this.stack.splice(i, 1);
99 | break;
100 | }
101 | }
102 | }
103 |
104 | // The handler stack handles chrome events (which may need to be suppressed) and internal (pseudo) events.
105 | // This checks whether the event at hand is a chrome event.
106 | isChromeEvent(event) {
107 | // TODO(philc): Shorten this.
108 | return ((event != null ? event.preventDefault : undefined) != null) || ((event != null ? event.stopImmediatePropagation : undefined) != null);
109 | }
110 |
111 | // Convenience wrappers. Handlers must return an approriate value. These are wrappers which handlers can
112 | // use to always return the same value. This then means that the handler itself can be implemented without
113 | // regard to its return value.
114 | alwaysContinueBubbling(handler = null) {
115 | if (typeof handler === 'function') {
116 | handler();
117 | }
118 | return this.continueBubbling;
119 | }
120 |
121 | alwaysSuppressPropagation(handler = null) {
122 | // TODO(philc): Shorten this.
123 | if ((typeof handler === 'function' ? handler() : undefined) === this.suppressEvent) { return this.suppressEvent; } else { return this.suppressPropagation; }
124 | }
125 |
126 | // Debugging.
127 | logResult(eventNumber, type, event, handler, result) {
128 | if ((event != null ? event.type : undefined) === "keydown") { // Tweak this as needed.
129 | let label =
130 | (() => { switch (result) {
131 | case this.passEventToPage: return "passEventToPage";
132 | case this.suppressEvent: return "suppressEvent";
133 | case this.suppressPropagation: return "suppressPropagation";
134 | case this.restartBubbling: return "restartBubbling";
135 | case "skip": return "skip";
136 | case true: return "continue";
137 | } })();
138 | if (!label) { label = result ? "continue/truthy" : "suppress"; }
139 | console.log(`${eventNumber}`, type, handler._name, label);
140 | }
141 | }
142 |
143 | show() {
144 | console.log(`${this.eventNumber}:`);
145 | for (let handler of this.stack.slice().reverse()) {
146 | console.log(" ", handler._name);
147 | }
148 | }
149 |
150 | // For tests only.
151 | reset() {
152 | this.stack = [];
153 | }
154 | }
155 |
156 | window.HandlerStack = HandlerStack;
157 | window.handlerStack = new HandlerStack();
158 |
--------------------------------------------------------------------------------
/test_harnesses/visibility_test.html:
--------------------------------------------------------------------------------
1 |
4 |
6 |
7 |
8 | Visibility test
9 |
29 |
30 |
133 |
134 |
135 |
136 |
137 |
138 |
139 | Node/Test
140 |
141 |
142 |
143 |
144 |
test
145 |
146 |
test
147 |
148 |
149 | test
150 |
151 |
152 |
test
153 |
154 |
155 | test
156 |
157 |
158 |
test
159 |
160 |
161 | test
162 |
163 |
164 | test
165 |
166 |
167 |
168 | test
169 |
170 |
171 |
172 |
173 |
--------------------------------------------------------------------------------
/background_scripts/bg_utils.js:
--------------------------------------------------------------------------------
1 | const TIME_DELTA = 500; // Milliseconds.
2 |
3 | // TabRecency associates a logical timestamp with each tab id. These are used to provide an initial
4 | // recency-based ordering in the tabs vomnibar (which allows jumping quickly between recently-visited tabs).
5 | class TabRecency {
6 |
7 | constructor() {
8 | this.timestamp = 1;
9 | this.current = -1;
10 | this.cache = {};
11 | this.lastVisited = null;
12 | this.lastVisitedTime = null;
13 |
14 | chrome.tabs.onActivated.addListener(activeInfo => this.register(activeInfo.tabId));
15 | chrome.tabs.onRemoved.addListener(tabId => this.deregister(tabId));
16 |
17 | chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
18 | this.deregister(removedTabId);
19 | this.register(addedTabId);
20 | });
21 |
22 | if (chrome.windows != null) {
23 | chrome.windows.onFocusChanged.addListener(wnd => {
24 | if (wnd !== chrome.windows.WINDOW_ID_NONE) {
25 | chrome.tabs.query({windowId: wnd, active: true}, tabs => {
26 | if (tabs[0])
27 | this.register(tabs[0].id);
28 | });
29 | }
30 | });
31 | }
32 | }
33 |
34 | register(tabId) {
35 | const currentTime = new Date();
36 | // Register tabId if it has been visited for at least @timeDelta ms. Tabs which are visited only for a
37 | // very-short time (e.g. those passed through with `5J`) aren't registered as visited at all.
38 | if ((this.lastVisitedTime != null) && (TIME_DELTA <= (currentTime - this.lastVisitedTime))) {
39 | this.cache[this.lastVisited] = ++this.timestamp;
40 | }
41 |
42 | this.current = (this.lastVisited = tabId);
43 | this.lastVisitedTime = currentTime;
44 | }
45 |
46 | deregister(tabId) {
47 | if (tabId === this.lastVisited) {
48 | // Ensure we don't register this tab, since it's going away.
49 | this.lastVisited = (this.lastVisitedTime = null);
50 | }
51 | delete this.cache[tabId];
52 | }
53 |
54 | // Recently-visited tabs get a higher score (except the current tab, which gets a low score).
55 | recencyScore(tabId) {
56 | if (!this.cache[tabId])
57 | this.cache[tabId] = 1;
58 | if (tabId === this.current)
59 | return 0.0;
60 | else
61 | return this.cache[tabId] / this.timestamp;
62 | }
63 |
64 | // Returns a list of tab Ids sorted by recency, most recent tab first.
65 | getTabsByRecency() {
66 | const tabIds = Object.keys(this.cache || {});
67 | tabIds.sort((a,b) => this.cache[b] - this.cache[a]);
68 | return tabIds.map(tId => parseInt(tId));
69 | }
70 | }
71 |
72 | var BgUtils = {
73 | tabRecency: new TabRecency(),
74 |
75 | // Log messages to the extension's logging page, but only if that page is open.
76 | log: (function() {
77 | const loggingPageUrl = chrome.runtime.getURL("pages/logging.html");
78 | if (loggingPageUrl != null) { console.log(`Vimium logging URL:\n ${loggingPageUrl}`); } // Do not output URL for tests.
79 | // For development, it's sometimes useful to automatically launch the logging page on reload.
80 | if (localStorage.autoLaunchLoggingPage) { chrome.windows.create({url: loggingPageUrl, focused: false}); }
81 | return function(message, sender = null) {
82 | for (let viewWindow of chrome.extension.getViews({type: "tab"})) {
83 | if (viewWindow.location.pathname === "/pages/logging.html") {
84 | // Don't log messages from the logging page itself. We do this check late because most of the time
85 | // it's not needed.
86 | if ((sender != null ? sender.url : undefined) !== loggingPageUrl) {
87 | const date = new Date;
88 | let [hours, minutes, seconds, milliseconds] =
89 | [date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()];
90 | if (minutes < 10) { minutes = "0" + minutes; }
91 | if (seconds < 10) { seconds = "0" + seconds; }
92 | if (milliseconds < 10) { milliseconds = "00" + milliseconds; }
93 | if (milliseconds < 100) { milliseconds = "0" + milliseconds; }
94 | const dateString = `${hours}:${minutes}:${seconds}.${milliseconds}`;
95 | const logElement = viewWindow.document.getElementById("log-text");
96 | logElement.value += `${dateString}: ${message}\n`;
97 | logElement.scrollTop = 2000000000;
98 | }
99 | }
100 | }
101 | };
102 | })(),
103 |
104 | // Remove comments and leading/trailing whitespace from a list of lines, and merge lines where the last
105 | // character on the preceding line is "\".
106 | parseLines(text) {
107 | return text.replace(/\\\n/g, "")
108 | .split("\n")
109 | .map(line => line.trim())
110 | .filter(line => (line.length > 0) && !(Array.from('#"').includes(line[0])));
111 | },
112 |
113 | escapedEntities: {
114 | '"': ""s;",
115 | '&': "&",
116 | "'": "'",
117 | "<": "<",
118 | ">": ">"
119 | },
120 |
121 | escapeAttribute(string) {
122 | return string.replace(/["&'<>]/g, char => BgUtils.escapedEntities[char]);
123 | }
124 | };
125 |
126 | // Utility for parsing and using the custom search-engine configuration. We re-use the previous parse if the
127 | // search-engine configuration is unchanged.
128 | const SearchEngines = {
129 | previousSearchEngines: null,
130 | searchEngines: null,
131 |
132 | refresh(searchEngines) {
133 | if ((this.previousSearchEngines == null) || (searchEngines !== this.previousSearchEngines)) {
134 | this.previousSearchEngines = searchEngines;
135 | this.searchEngines = new AsyncDataFetcher(function(callback) {
136 | const engines = {};
137 | for (let line of BgUtils.parseLines(searchEngines)) {
138 | const tokens = line.split(/\s+/);
139 | if (2 <= tokens.length) {
140 | const keyword = tokens[0].split(":")[0];
141 | const searchUrl = tokens[1];
142 | const description = tokens.slice(2).join(" ") || `search (${keyword})`;
143 | if (Utils.hasFullUrlPrefix(searchUrl) || Utils.hasJavascriptPrefix(searchUrl))
144 | engines[keyword] = {keyword, searchUrl, description};
145 | }
146 | }
147 |
148 | callback(engines);
149 | });
150 | }
151 | },
152 |
153 | // Use the parsed search-engine configuration, possibly asynchronously.
154 | use(callback) { this.searchEngines.use(callback); },
155 |
156 | // Both set (refresh) the search-engine configuration and use it at the same time.
157 | refreshAndUse(searchEngines, callback) {
158 | this.refresh(searchEngines);
159 | this.use(callback);
160 | }
161 | };
162 |
163 | BgUtils.TIME_DELTA = TIME_DELTA; // Referenced by our tests.
164 |
165 | window.SearchEngines = SearchEngines;
166 | window.BgUtils = BgUtils;
167 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Vimium - The Hacker's Browser
2 | =============================
3 |
4 | Vimium is a browser extension that provides keyboard-based navigation and control of the web in the spirit of
5 | the Vim editor.
6 |
7 | __Installation instructions:__
8 |
9 | Install via the
10 | [Chrome web store](https://chrome.google.com/extensions/detail/dbepggeogbaibhgnhhndojpepiihcmeb) or
11 | the [Firefox Addons site](https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/).
12 |
13 | To install from source, see [here](CONTRIBUTING.md#installing-from-source).
14 |
15 | Vimium's Options page can be reached via a link on the help dialog (type `?`) or via the button next to Vimium
16 | on the extension pages of Chrome (`chrome://extensions`) or Firefox (`about:addons`).
17 |
18 | Keyboard Bindings
19 | -----------------
20 |
21 | Modifier keys are specified as ``, ``, and `` for ctrl+x, meta+x, and alt+x
22 | respectively. For shift+x and ctrl-shift-x, just type `X` and ``. See the next section for how to
23 | customize these bindings.
24 |
25 | Once you have Vimium installed, you can see this list of key bindings at any time by typing `?`.
26 |
27 | Navigating the current page:
28 |
29 | ? show the help dialog for a list of all available keys
30 | h scroll left
31 | j scroll down
32 | k scroll up
33 | l scroll right
34 | gg scroll to top of the page
35 | G scroll to bottom of the page
36 | d scroll down half a page
37 | u scroll up half a page
38 | f open a link in the current tab
39 | F open a link in a new tab
40 | r reload
41 | gs view source
42 | i enter insert mode -- all commands will be ignored until you hit Esc to exit
43 | yy copy the current url to the clipboard
44 | yf copy a link url to the clipboard
45 | gf cycle forward to the next frame
46 | gF focus the main/top frame
47 |
48 | Navigating to new pages:
49 |
50 | o Open URL, bookmark, or history entry
51 | O Open URL, bookmark, history entry in a new tab
52 | b Open bookmark
53 | B Open bookmark in a new tab
54 |
55 | Using find:
56 |
57 | / enter find mode
58 | -- type your search query and hit enter to search, or Esc to cancel
59 | n cycle forward to the next find match
60 | N cycle backward to the previous find match
61 |
62 | For advanced usage, see [regular expressions](https://github.com/philc/vimium/wiki/Find-Mode) on the wiki.
63 |
64 | Navigating your history:
65 |
66 | H go back in history
67 | L go forward in history
68 |
69 | Manipulating tabs:
70 |
71 | J, gT go one tab left
72 | K, gt go one tab right
73 | g0 go to the first tab. Use ng0 to go to n-th tab
74 | g$ go to the last tab
75 | ^ visit the previously-visited tab
76 | t create tab
77 | yt duplicate current tab
78 | x close current tab
79 | X restore closed tab (i.e. unwind the 'x' command)
80 | T search through your open tabs
81 | W move current tab to new window
82 | pin/unpin current tab
83 |
84 | Using marks:
85 |
86 | ma, mA set local mark "a" (global mark "A")
87 | `a, `A jump to local mark "a" (global mark "A")
88 | `` jump back to the position before the previous jump
89 | -- that is, before the previous gg, G, n, N, / or `a
90 |
91 | Additional advanced browsing commands:
92 |
93 | ]], [[ Follow the link labeled 'next' or '>' ('previous' or '<')
94 | - helpful for browsing paginated sites
95 | open multiple links in a new tab
96 | gi focus the first (or n-th) text input box on the page. Use to cycle through options.
97 | gu go up one level in the URL hierarchy
98 | gU go up to root of the URL hierarchy
99 | ge edit the current URL
100 | gE edit the current URL and open in a new tab
101 | zH scroll all the way left
102 | zL scroll all the way right
103 | v enter visual mode; use p/P to paste-and-go, use y to yank
104 | V enter visual line mode
105 |
106 | Vimium supports command repetition so, for example, hitting `5t` will open 5 tabs in rapid succession. ``
107 | (or ``) will clear any partial commands in the queue and will also exit insert and find modes.
108 |
109 | There are some advanced commands which aren't documented here; refer to the help dialog (type `?`) for a full
110 | list.
111 |
112 | Custom Key Mappings
113 | -------------------
114 |
115 | You may remap or unmap any of the default key bindings in the "Custom key mappings" on the options page.
116 |
117 | Enter one of the following key mapping commands per line:
118 |
119 | - `map key command`: Maps a key to a Vimium command. Overrides Chrome's default behavior (if any).
120 | - `unmap key`: Unmaps a key and restores Chrome's default behavior (if any).
121 | - `unmapAll`: Unmaps all bindings. This is useful if you want to completely wipe Vimium's defaults and start
122 | from scratch with your own setup.
123 |
124 | Examples:
125 |
126 | - `map scrollPageDown` maps ctrl+d to scrolling the page down. Chrome's default behavior of bringing up
127 | a bookmark dialog is suppressed.
128 | - `map r reload` maps the r key to reloading the page.
129 | - `unmap ` removes any mapping for ctrl+d and restores Chrome's default behavior.
130 | - `unmap r` removes any mapping for the r key.
131 |
132 | Available Vimium commands can be found via the "Show available commands" link
133 | near the key mapping box on the options page. The command name appears to the
134 | right of the description in parenthesis.
135 |
136 | You can add comments to key mappings by starting a line with `"` or `#`.
137 |
138 | The following special keys are available for mapping:
139 |
140 | - ``, ``, ``, `` for ctrl, alt, shift, and meta (command on Mac) respectively with any key. Replace `*`
141 | with the key of choice.
142 | - ``, ``, ``, `` for the arrow keys.
143 | - `` through `` for the function keys.
144 | - `` for the space key.
145 | - ``, ``, ``, ``, ``, `` and `` for the corresponding non-printable keys (version 1.62 onwards).
146 |
147 | Shifts are automatically detected so, for example, `` corresponds to ctrl+shift+7 on an English keyboard.
148 |
149 | More documentation
150 | ------------------
151 | Many of the more advanced or involved features are documented on
152 | [Vimium's GitHub wiki](https://github.com/philc/vimium/wiki). Also
153 | see the [FAQ](https://github.com/philc/vimium/wiki/FAQ).
154 |
155 | Contributing
156 | ------------
157 | See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
158 |
159 | Release Notes
160 | -------------
161 |
162 | See [CHANGELOG](CHANGELOG.md) for the major changes in each release.
163 |
164 | License
165 | -------
166 | Copyright (c) Phil Crosby, Ilya Sukhar. See [MIT-LICENSE.txt](MIT-LICENSE.txt) for details.
167 |
--------------------------------------------------------------------------------
/pages/hud.js:
--------------------------------------------------------------------------------
1 | let findMode = null;
2 |
3 | // Chrome creates a unique port for each MessageChannel, so there's a race condition between JavaScript
4 | // messages of Vimium and browser messages during style recomputation. This duration was determined
5 | // empirically. See https://github.com/philc/vimium/pull/3277#discussion_r283080348
6 | const TIME_TO_WAIT_FOR_IPC_MESSAGES = 17;
7 |
8 | // Set the input element's text, and move the cursor to the end.
9 | const setTextInInputElement = function(inputElement, text) {
10 | inputElement.textContent = text;
11 | // Move the cursor to the end. Based on one of the solutions here:
12 | // http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity
13 | const range = document.createRange();
14 | range.selectNodeContents(inputElement);
15 | range.collapse(false);
16 | const selection = window.getSelection();
17 | selection.removeAllRanges();
18 | selection.addRange(range);
19 | };
20 |
21 | // Manually inject custom user styles.
22 | document.addEventListener("DOMContentLoaded", () => DomUtils.injectUserCss());
23 |
24 | const onKeyEvent = function(event) {
25 | // Handle on "keypress", and other events on "keydown"; this avoids interence with CJK translation
26 | // (see #2915 and #2934).
27 | let rawQuery;
28 | if ((event.type === "keypress") && (event.key !== "Enter"))
29 | return null;
30 | if ((event.type === "keydown") && (event.key === "Enter"))
31 | return null;
32 |
33 | const inputElement = document.getElementById("hud-find-input");
34 | if (inputElement == null) // Don't do anything if we're not in find mode.
35 | return;
36 |
37 | if ((KeyboardUtils.isBackspace(event) && (inputElement.textContent.length === 0)) ||
38 | (event.key === "Enter") || KeyboardUtils.isEscape(event)) {
39 |
40 | inputElement.blur();
41 | UIComponentServer.postMessage({
42 | name: "hideFindMode",
43 | exitEventIsEnter: event.key === "Enter",
44 | exitEventIsEscape: KeyboardUtils.isEscape(event)
45 | });
46 | } else if (event.key === "ArrowUp") {
47 | if (rawQuery = FindModeHistory.getQuery(findMode.historyIndex + 1)) {
48 | findMode.historyIndex += 1;
49 | if (findMode.historyIndex === 0) {
50 | findMode.partialQuery = findMode.rawQuery;
51 | }
52 | setTextInInputElement(inputElement, rawQuery);
53 | findMode.executeQuery();
54 | }
55 | } else if (event.key === "ArrowDown") {
56 | findMode.historyIndex = Math.max(-1, findMode.historyIndex - 1);
57 | rawQuery = 0 <= findMode.historyIndex ? FindModeHistory.getQuery(findMode.historyIndex) : findMode.partialQuery;
58 | setTextInInputElement(inputElement, rawQuery);
59 | findMode.executeQuery();
60 | } else {
61 | return;
62 | }
63 |
64 | DomUtils.suppressEvent(event);
65 | return false;
66 | };
67 |
68 | document.addEventListener("keydown", onKeyEvent);
69 | document.addEventListener("keypress", onKeyEvent);
70 |
71 | const handlers = {
72 | show(data) {
73 | document.getElementById("hud").innerText = data.text;
74 | document.getElementById("hud").classList.add("vimiumUIComponentVisible");
75 | document.getElementById("hud").classList.remove("vimiumUIComponentHidden");
76 | document.getElementById("hud").classList.remove("hud-find");
77 | },
78 | hidden() {
79 | // We get a flicker when the HUD later becomes visible again (with new text) unless we reset its contents
80 | // here.
81 | document.getElementById("hud").innerText = "";
82 | document.getElementById("hud").classList.add("vimiumUIComponentHidden");
83 | document.getElementById("hud").classList.remove("vimiumUIComponentVisible");
84 | },
85 |
86 | showFindMode(data) {
87 | let executeQuery;
88 | const hud = document.getElementById("hud");
89 | hud.classList.add("hud-find");
90 |
91 | const inputElement = document.createElement("span");
92 | try { // NOTE(mrmr1993): Chrome supports non-standard "plaintext-only", which is what we *really* want.
93 | inputElement.contentEditable = "plaintext-only";
94 | } catch (error) { // Fallback to standard-compliant version.
95 | inputElement.contentEditable = "true";
96 | }
97 | inputElement.id = "hud-find-input";
98 | hud.appendChild(inputElement);
99 |
100 | inputElement.addEventListener("input", (executeQuery = function(event) {
101 | // On Chrome when IME is on, the order of events is:
102 | // keydown, input.isComposing=true, keydown, input.true, ..., keydown, input.true, compositionend;
103 | // while on Firefox, the order is: keydown, input.true, ..., input.true, keydown, compositionend, input.false.
104 | // Therefore, check event.isComposing here, to avoid window focus changes during typing with IME,
105 | // since such changes will prevent normal typing on Firefox (see #3480)
106 | if (Utils.isFirefox() && event.isComposing)
107 | return;
108 | // Replace \u00A0 ( ) with a normal space.
109 | findMode.rawQuery = inputElement.textContent.replace("\u00A0", " ");
110 | UIComponentServer.postMessage({name: "search", query: findMode.rawQuery});
111 | }));
112 |
113 | const countElement = document.createElement("span");
114 | countElement.id = "hud-match-count";
115 | countElement.style.float = "right";
116 | hud.appendChild(countElement);
117 | Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, function() {
118 | // On Firefox, the page must first be focused before the HUD input element can be focused. #3460.
119 | if (Utils.isFirefox())
120 | window.focus();
121 | inputElement.focus();
122 | });
123 |
124 | findMode = {
125 | historyIndex: -1,
126 | partialQuery: "",
127 | rawQuery: "",
128 | executeQuery
129 | };
130 | },
131 |
132 | updateMatchesCount({matchCount, showMatchText}) {
133 | const countElement = document.getElementById("hud-match-count");
134 | if (countElement == null) // Don't do anything if we're not in find mode.
135 | return;
136 |
137 | if (Utils.isFirefox())
138 | document.getElementById("hud-find-input").focus()
139 | const countText = matchCount > 0 ?
140 | ` (${matchCount} Match${matchCount === 1 ? "" : "es"})` : " (No matches)";
141 | countElement.textContent = showMatchText ? countText : "";
142 | },
143 |
144 | copyToClipboard(data) {
145 | Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, function() {
146 | const focusedElement = document.activeElement;
147 | Clipboard.copy(data);
148 | if (focusedElement != null)
149 | focusedElement.focus();
150 | window.parent.focus();
151 | UIComponentServer.postMessage({name: "unfocusIfFocused"});
152 | });
153 | },
154 |
155 | pasteFromClipboard() {
156 | Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, function() {
157 | const focusedElement = document.activeElement;
158 | const data = Clipboard.paste();
159 | if (focusedElement != null)
160 | focusedElement.focus();
161 | window.parent.focus();
162 | UIComponentServer.postMessage({name: "pasteResponse", data});
163 | });
164 | },
165 |
166 | settings({ isFirefox }) {
167 | return Utils.isFirefox = () => isFirefox;
168 | }
169 | };
170 |
171 | UIComponentServer.registerHandler(function({data}) {
172 | const handler = handlers[data.name || data];
173 | if (handler)
174 | return handler(data);
175 | });
176 |
177 | FindModeHistory.init();
178 |
--------------------------------------------------------------------------------
/make.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env deno run --allow-read --allow-write --allow-env --allow-net --allow-run --unstable
2 | // --unstable is required for Puppeteer.
3 | // Usage: ./make.js command. Use -l to list commands.
4 | // This is a set of tasks for building and testing Vimium in development.
5 | import * as fs from "https://deno.land/std/fs/mod.ts";
6 | import * as fsCopy from "https://deno.land/std@0.122.0/fs/copy.ts";
7 | import * as path from "https://deno.land/std@0.136.0/path/mod.ts";
8 | import { desc, run, task } from "https://deno.land/x/drake@v1.5.1/mod.ts";
9 | import puppeteer from "https://deno.land/x/puppeteer@9.0.2/mod.ts";
10 | import * as shoulda from "./tests/vendor/shoulda.js";
11 |
12 | const projectPath = new URL(".", import.meta.url).pathname;
13 |
14 | async function shell(procName, argsArray = []) {
15 | // NOTE(philc): Does drake's `sh` function work on Windows? If so, that can replace this function.
16 | if (Deno.build.os == "windows") {
17 | // if win32, prefix arguments with "/c {original command}"
18 | // e.g. "mkdir c:\git\vimium" becomes "cmd.exe /c mkdir c:\git\vimium"
19 | optArray.unshift("/c", procName)
20 | procName = "cmd.exe"
21 | }
22 | const p = Deno.run({ cmd: [procName].concat(argsArray) });
23 | const status = await p.status();
24 | if (!status.success)
25 | throw new Error(`${procName} ${argsArray} exited with status ${status.code}`);
26 | }
27 |
28 | // Builds a zip file for submission to the Chrome and Firefox stores. The output is in dist/.
29 | async function buildStorePackage() {
30 | const excludeList = [
31 | "*.md",
32 | ".*",
33 | "CREDITS",
34 | "MIT-LICENSE.txt",
35 | "dist",
36 | "make.js",
37 | "node_modules",
38 | "package-lock.json",
39 | "test_harnesses",
40 | "tests",
41 | ];
42 | const fileContents = await Deno.readTextFile("./manifest.json");
43 | const manifestContents = JSON.parse(fileContents);
44 | const rsyncOptions = ["-r", ".", "dist/vimium"].concat(
45 | ...excludeList.map((item) => ["--exclude", item])
46 | );
47 | const vimiumVersion = manifestContents["version"];
48 | const writeDistManifest = async (manifestObject) => {
49 | await Deno.writeTextFile("dist/vimium/manifest.json", JSON.stringify(manifestObject, null, 2));
50 | };
51 | // cd into "dist/vimium" before building the zip, so that the files in the zip don't each have the
52 | // path prefix "dist/vimium".
53 | // --filesync ensures that files in the archive which are no longer on disk are deleted. It's equivalent to
54 | // removing the zip file before the build.
55 | const zipCommand = "cd dist/vimium && zip -r --filesync ";
56 |
57 | await shell("rm", ["-rf", "dist/vimium"]);
58 | await shell("mkdir", ["-p", "dist/vimium", "dist/chrome-canary", "dist/chrome-store", "dist/firefox"]);
59 | await shell("rsync", rsyncOptions);
60 |
61 | writeDistManifest(Object.assign({}, manifestContents, {
62 | // Chrome considers this key invalid in manifest.json, so we add it only during the Firefox build phase.
63 | browser_specific_settings: {
64 | gecko: {
65 | strict_min_version: "62.0"
66 | },
67 | },
68 | }));
69 | await shell("bash", ["-c", `${zipCommand} ../firefox/vimium-firefox-${vimiumVersion}.zip .`]);
70 |
71 | // Build the Chrome Store package. Chrome does not require the clipboardWrite permission.
72 | const permissions = manifestContents.permissions.filter((p) => p != "clipboardWrite");
73 | writeDistManifest(Object.assign({}, manifestContents, {
74 | permissions,
75 | }));
76 | await shell("bash", ["-c", `${zipCommand} ../chrome-store/vimium-chrome-store-${vimiumVersion}.zip .`]);
77 |
78 | // Build the Chrome Store dev package.
79 | writeDistManifest(Object.assign({}, manifestContents, {
80 | name: "Vimium Canary",
81 | description: "This is the development branch of Vimium (it is beta software).",
82 | permissions,
83 | }));
84 | await shell("bash", ["-c", `${zipCommand} ../chrome-canary/vimium-canary-${vimiumVersion}.zip .`]);
85 | }
86 |
87 | const runUnitTests = async () => {
88 | // Import every test file.
89 | const dir = path.join(projectPath, "tests/unit_tests");
90 | const files = Array.from(Deno.readDirSync(dir)).map((f) => f.name).sort();
91 | for (let f of files) {
92 | if (f.endsWith("_test.js")) {
93 | await import(path.join(dir, f));
94 | }
95 | }
96 |
97 | await shoulda.run();
98 | };
99 |
100 | const runDomTests = async () => {
101 | const testFile = `${projectPath}/tests/dom_tests/dom_tests.html`;
102 |
103 | await (async () => {
104 | const browser = await puppeteer.launch({
105 | // NOTE(philc): "Disabling web security" is required for vomnibar_test.js, because we have a file://
106 | // page accessing an iframe, and Chrome prevents this because it's a cross-origin request.
107 | args: ['--disable-web-security']
108 | });
109 |
110 | const page = await browser.newPage();
111 | page.on("console", msg => console.log(msg.text()));
112 | page.on("error", (err) => console.log(err));
113 | page.on("pageerror", (err) => console.log(err));
114 | page.on('requestfailed', request =>
115 | console.log(console.log(`${request.failure().errorText} ${request.url()}`)));
116 |
117 | // Shoulda.js is an ECMAScript module, and those cannot be loaded over file:/// protocols due to a Chrome
118 | // security restriction, and this test suite loads the dom_tests.html page from the local file system. To
119 | // (painfully) work around this, we're injecting the contents of shoulda.js into the page. We munge the
120 | // file contents and assign it to a string (`shouldaJsContents`), and then have the page itself
121 | // document.write that string during load (the document.write call is in dom_tests.html).
122 | // Another workaround would be to spin up a local file server here and load dom_tests from the network.
123 | // Discussion: https://bugs.chromium.org/p/chromium/issues/detail?id=824651
124 | let shouldaJsContents =
125 | (await Deno.readTextFile("./tests/vendor/shoulda.js")) +
126 | "\n" +
127 | // Export the module contents to window.shoulda, which is what the tests expect.
128 | "window.shoulda = {assert, context, ensureCalled, getStats, reset, run, setup, should, stub, tearDown};";
129 |
130 | // Remove the `export` statement from the shoulda.js module. Because we're using document.write to add
131 | // this, an export statement will cause a JS error and halt further parsing.
132 | shouldaJsContents = shouldaJsContents.replace(/export {[^}]+}/, "");
133 |
134 | await page.evaluateOnNewDocument((content) => {
135 | window.shouldaJsContents = content;
136 | },
137 | shouldaJsContents);
138 |
139 | page.goto("file://" + testFile);
140 |
141 | await page.waitForNavigation({ waitUntil: "load" });
142 |
143 | const testsFailed = await page.evaluate(() => {
144 | shoulda.run();
145 | return shoulda.getStats().failed;
146 | });
147 |
148 | // NOTE(philc): At one point in development, I noticed that the output from Deno would suddenly pause,
149 | // prior to the tests fully finishing, so closing the browser here may be racy. If it occurs again, we may
150 | // need to add "await delay(200)".
151 | await browser.close();
152 | return testsFailed;
153 | })();
154 | };
155 |
156 | desc("Run unit tests");
157 | task("test-unit", [], async () => {
158 | const failed = await runUnitTests();
159 | if (failed > 0)
160 | console.log("Failed:", failed);
161 | });
162 |
163 | desc("Run DOM tests");
164 | task("test-dom", [], async () => {
165 | const failed = await runDomTests();
166 | if (failed > 0)
167 | console.log("Failed:", failed);
168 | });
169 |
170 | desc("Run unit and DOM tests");
171 | task("test", [], async () => {
172 | const failed = (await runUnitTests()) + (await runDomTests());
173 | if (failed > 0)
174 | console.log("Failed:", failed);
175 | });
176 |
177 | desc("Builds a zip file for submission to the Chrome and Firefox stores. The output is in dist/");
178 | task("package", [], async () => {
179 | await buildStorePackage();
180 | });
181 |
182 | run();
183 |
--------------------------------------------------------------------------------
/content_scripts/ui_component.js:
--------------------------------------------------------------------------------
1 | class UIComponent {
2 |
3 | constructor(iframeUrl, className, handleMessage) {
4 | this.handleMessage = handleMessage;
5 | this.iframeElement = null;
6 | this.iframePort = null;
7 | this.showing = false;
8 | this.iframeFrameId = null;
9 | // TODO(philc): Make the @options object default to {} and remove the null checks.
10 | this.options = null;
11 | this.shadowDOM = null;
12 |
13 | DomUtils.documentReady(() => {
14 | const styleSheet = DomUtils.createElement("style");
15 | styleSheet.type = "text/css";
16 | // Default to everything hidden while the stylesheet loads.
17 | styleSheet.innerHTML = "iframe {display: none;}";
18 |
19 | // Fetch "content_scripts/vimium.css" from chrome.storage.local; the background page caches it there.
20 | chrome.storage.local.get("vimiumCSSInChromeStorage",
21 | items => styleSheet.innerHTML = items.vimiumCSSInChromeStorage);
22 |
23 | this.iframeElement = DomUtils.createElement("iframe");
24 | Object.assign(this.iframeElement, {
25 | className,
26 | seamless: "seamless"
27 | });
28 |
29 | const shadowWrapper = DomUtils.createElement("div");
30 | // Firefox doesn't support createShadowRoot, so guard against its non-existance.
31 | // https://hacks.mozilla.org/2018/10/firefox-63-tricks-and-treats/ says
32 | // Firefox 63 has enabled Shadow DOM v1 by default
33 | if (shadowWrapper.attachShadow)
34 | this.shadowDOM = shadowWrapper.attachShadow({mode: "open"});
35 | else
36 | this.shadowDOM = shadowWrapper;
37 |
38 | this.shadowDOM.appendChild(styleSheet);
39 | this.shadowDOM.appendChild(this.iframeElement);
40 | this.handleDarkReaderFilter();
41 | this.toggleIframeElementClasses("vimiumUIComponentVisible", "vimiumUIComponentHidden");
42 |
43 | // Open a port and pass it to the iframe via window.postMessage. We use an AsyncDataFetcher to handle
44 | // requests which arrive before the iframe (and its message handlers) have completed initialization. See
45 | // #1679.
46 | this.iframePort = new AsyncDataFetcher(setIframePort => {
47 | // We set the iframe source and append the new element here (as opposed to above) to avoid a potential
48 | // race condition vis-a-vis the "load" event (because this callback runs on "nextTick").
49 | this.iframeElement.src = chrome.runtime.getURL(iframeUrl);
50 | document.documentElement.appendChild(shadowWrapper);
51 |
52 | this.iframeElement.addEventListener("load", () => {
53 | // Get vimiumSecret so the iframe can determine that our message isn't the page impersonating us.
54 | chrome.storage.local.get("vimiumSecret", ({ vimiumSecret }) => {
55 | const { port1, port2 } = new MessageChannel;
56 | this.iframeElement.contentWindow.postMessage(vimiumSecret, chrome.runtime.getURL(""), [ port2 ]);
57 | port1.onmessage = event => {
58 | let eventName = null;
59 | if (event)
60 | eventName = (event.data ? event.data.name : undefined) || event.data;
61 |
62 | switch (eventName) {
63 | case "uiComponentIsReady":
64 | // If any other frame receives the focus, then hide the UI component.
65 | chrome.runtime.onMessage.addListener(({name, focusFrameId}) => {
66 | if ((name === "frameFocused") && this.options && this.options.focus &&
67 | ![frameId, this.iframeFrameId].includes(focusFrameId)) {
68 | this.hide(false);
69 | }
70 | // We will not be calling sendResponse.
71 | return false;
72 | });
73 | // If this frame receives the focus, then hide the UI component.
74 | window.addEventListener("focus", (forTrusted(event => {
75 | if ((event.target === window) && this.options && this.options.focus)
76 | this.hide(false);
77 | // Continue propagating the event.
78 | return true;
79 | })), true);
80 | // Set the iframe's port, thereby rendering the UI component ready.
81 | setIframePort(port1);
82 | break;
83 | case "setIframeFrameId":
84 | this.iframeFrameId = event.data.iframeFrameId;
85 | break;
86 | case "hide":
87 | return this.hide();
88 | break;
89 | default:
90 | this.handleMessage(event);
91 | }
92 | };
93 | });
94 | });
95 | });
96 | if (Utils.isFirefox()) {
97 | this.postMessage({name: "settings", isFirefox: true});
98 | }
99 | });
100 | }
101 |
102 | // This ensures that Vimium's UI elements (HUD, Vomnibar) honor the browser's light/dark theme preference,
103 | // even when the user is also using the DarkReader extension. DarkReader is the most popular dark mode
104 | // Chrome extension in use as of 2020.
105 | handleDarkReaderFilter() {
106 | const reverseFilterClass = "reverseDarkReaderFilter";
107 |
108 | const reverseFilterIfExists = () => {
109 | // The DarkReader extension creates this element if it's actively modifying the current page.
110 | const darkReaderElement = document.getElementById("dark-reader-style");
111 | if (darkReaderElement && darkReaderElement.innerHTML.includes("filter"))
112 | this.iframeElement.classList.add(reverseFilterClass);
113 | else
114 | this.iframeElement.classList.remove(reverseFilterClass);
115 | };
116 |
117 | reverseFilterIfExists();
118 |
119 | const observer = new MutationObserver(reverseFilterIfExists);
120 | observer.observe(document.head, { characterData: true, subtree: true, childList: true });
121 | };
122 |
123 | toggleIframeElementClasses(removeClass, addClass) {
124 | this.iframeElement.classList.remove(removeClass);
125 | this.iframeElement.classList.add(addClass);
126 | }
127 |
128 | // Post a message (if provided), then call continuation (if provided). We wait for documentReady() to ensure
129 | // that the @iframePort set (so that we can use @iframePort.use()).
130 | postMessage(message = null, continuation = null) {
131 | if (!this.iframePort)
132 | return
133 |
134 | this.iframePort.use(function(port) {
135 | if (message != null)
136 | port.postMessage(message);
137 | if (continuation)
138 | continuation();
139 | });
140 | }
141 |
142 | activate(options = null) {
143 | this.options = options;
144 | this.postMessage(this.options, () => {
145 | this.toggleIframeElementClasses("vimiumUIComponentHidden", "vimiumUIComponentVisible");
146 | if (this.options && this.options.focus)
147 | this.iframeElement.focus();
148 | this.showing = true;
149 | });
150 | }
151 |
152 | hide(shouldRefocusOriginalFrame) {
153 | // We post a non-message (null) to ensure that hide() requests cannot overtake activate() requests.
154 | if (shouldRefocusOriginalFrame == null) { shouldRefocusOriginalFrame = true; }
155 | this.postMessage(null, () => {
156 | if (!this.showing) { return; }
157 | this.showing = false;
158 | this.toggleIframeElementClasses("vimiumUIComponentVisible", "vimiumUIComponentHidden");
159 | if (this.options && this.options.focus) {
160 | this.iframeElement.blur();
161 | if (shouldRefocusOriginalFrame) {
162 | if (this.options && (this.options.sourceFrameId != null)) {
163 | chrome.runtime.sendMessage({
164 | handler: "sendMessageToFrames",
165 | message: { name: "focusFrame", frameId: this.options.sourceFrameId, forceFocusThisFrame: true }
166 | });
167 | } else {
168 | Utils.nextTick(() => window.focus());
169 | }
170 | }
171 | }
172 | this.options = null;
173 | this.postMessage("hidden"); // Inform the UI component that it is hidden.
174 | });
175 | }
176 | }
177 |
178 | window.UIComponent = UIComponent;
179 |
--------------------------------------------------------------------------------
/pages/help_dialog.js:
--------------------------------------------------------------------------------
1 | /*
2 | * decaffeinate suggestions:
3 | * DS101: Remove unnecessary use of Array.from
4 | * DS102: Remove unnecessary code created because of implicit returns
5 | * DS203: Remove `|| {}` from converted for-own loops
6 | * DS207: Consider shorter variations of null checks
7 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
8 | */
9 | const $ = id => document.getElementById(id);
10 | const $$ = (element, selector) => element.querySelector(selector);
11 |
12 | // The ordering we show key bindings is alphanumerical, except that special keys sort to the end.
13 | const compareKeys = function(a,b) {
14 | a = a.replace("<","~");
15 | b = b.replace("<", "~");
16 | if (a < b)
17 | return -1;
18 | else if (b < a)
19 | return 1;
20 | else
21 | return 0;
22 | };
23 |
24 | // This overrides the HelpDialog implementation in vimium_frontend.js. We provide aliases for the two
25 | // HelpDialog methods required by normalMode (isShowing() and toggle()).
26 | const HelpDialog = {
27 | dialogElement: null,
28 | isShowing() { return true; },
29 |
30 | // This setting is pulled out of local storage. It's false by default.
31 | getShowAdvancedCommands() { return Settings.get("helpDialog_showAdvancedCommands"); },
32 |
33 | init() {
34 | if (this.dialogElement != null)
35 | return;
36 | this.dialogElement = document.getElementById("vimiumHelpDialog");
37 |
38 | this.dialogElement.getElementsByClassName("closeButton")[0].addEventListener("click", clickEvent => {
39 | clickEvent.preventDefault();
40 | this.hide();
41 | }, false);
42 |
43 | document.getElementById("helpDialogOptionsPage").addEventListener("click", function(clickEvent) {
44 | clickEvent.preventDefault();
45 | chrome.runtime.sendMessage({handler: "openOptionsPageInNewTab"});
46 | }, false);
47 |
48 | document.getElementById("toggleAdvancedCommands").
49 | addEventListener("click", HelpDialog.toggleAdvancedCommands.bind(HelpDialog), false);
50 |
51 | document.documentElement.addEventListener("click", event => {
52 | if (!this.dialogElement.contains(event.target))
53 | this.hide();
54 | } , false);
55 | },
56 |
57 | instantiateHtmlTemplate(parentNode, templateId, callback) {
58 | const templateContent = document.querySelector(templateId).content;
59 | const node = document.importNode(templateContent, true);
60 | parentNode.appendChild(node);
61 | callback(parentNode.lastElementChild);
62 | },
63 |
64 | show({showAllCommandDetails}) {
65 | $("help-dialog-title").textContent = showAllCommandDetails ? "Command Listing" : "Help";
66 | $("help-dialog-version").textContent = Utils.getCurrentVersion();
67 |
68 | chrome.storage.local.get("helpPageData", ({helpPageData}) => {
69 | for (let group of Object.keys(helpPageData)) {
70 | const commands = helpPageData[group];
71 | const container = this.dialogElement.querySelector(`#help-dialog-${group}`);
72 | container.innerHTML = "";
73 |
74 | for (var command of Array.from(commands)) {
75 | if (!showAllCommandDetails && command.keys.length == 0)
76 | continue;
77 |
78 | let keysElement = null;
79 | let descriptionElement = null;
80 |
81 | const useTwoRows = command.keys.join(", ").length >= 12;
82 | if (!useTwoRows) {
83 | this.instantiateHtmlTemplate(container, "#helpDialogEntry", function(element) {
84 | if (command.advanced)
85 | element.classList.add("advanced");
86 | keysElement = (descriptionElement = element);
87 | });
88 | } else {
89 | this.instantiateHtmlTemplate(container, "#helpDialogEntryBindingsOnly", function(element) {
90 | if (command.advanced)
91 | element.classList.add("advanced");
92 | keysElement = element;
93 | });
94 | this.instantiateHtmlTemplate(container, "#helpDialogEntry", function(element) {
95 | if (command.advanced)
96 | element.classList.add("advanced");
97 | descriptionElement = element;
98 | });
99 | }
100 |
101 | $$(descriptionElement, ".vimiumHelpDescription").textContent = command.description;
102 |
103 | keysElement = $$(keysElement, ".vimiumKeyBindings");
104 | let lastElement = null;
105 | for (var key of command.keys.sort(compareKeys)) {
106 | this.instantiateHtmlTemplate(keysElement, "#keysTemplate", function(element) {
107 | lastElement = element;
108 | $$(element, ".vimiumHelpDialogKey").textContent = key;
109 | });
110 | }
111 |
112 | // And strip off the trailing ", ", if necessary.
113 | if (lastElement)
114 | lastElement.removeChild($$(lastElement, ".commaSeparator"));
115 |
116 | if (showAllCommandDetails) {
117 | this.instantiateHtmlTemplate($$(descriptionElement, ".vimiumHelpDescription"), "#commandNameTemplate", function(element) {
118 | const commandNameElement = $$(element, ".vimiumCopyCommandNameName");
119 | commandNameElement.textContent = command.command;
120 | commandNameElement.title = `Click to copy \"${command.command}\" to clipboard.`;
121 | commandNameElement.addEventListener("click", function() {
122 | HUD.copyToClipboard(commandNameElement.textContent);
123 | HUD.showForDuration(`Yanked ${commandNameElement.textContent}.`, 2000);
124 | });
125 | });
126 | }
127 | // }
128 | }
129 | }
130 |
131 | this.showAdvancedCommands(this.getShowAdvancedCommands());
132 |
133 | // "Click" the dialog element (so that it becomes scrollable).
134 | DomUtils.simulateClick(this.dialogElement);
135 | });
136 | },
137 |
138 | hide() {
139 | UIComponentServer.hide();
140 | },
141 |
142 | toggle() {
143 | this.hide();
144 | },
145 |
146 | //
147 | // Advanced commands are hidden by default so they don't overwhelm new and casual users.
148 | //
149 | toggleAdvancedCommands(event) {
150 | const vimiumHelpDialogContainer = $("vimiumHelpDialogContainer");
151 | const scrollHeightBefore = vimiumHelpDialogContainer.scrollHeight;
152 | event.preventDefault();
153 | const showAdvanced = HelpDialog.getShowAdvancedCommands();
154 | HelpDialog.showAdvancedCommands(!showAdvanced);
155 | Settings.set("helpDialog_showAdvancedCommands", !showAdvanced);
156 | // Try to keep the "show advanced commands" button in the same scroll position.
157 | const scrollHeightDelta = vimiumHelpDialogContainer.scrollHeight - scrollHeightBefore;
158 | if (scrollHeightDelta > 0)
159 | vimiumHelpDialogContainer.scrollTop += scrollHeightDelta;
160 | },
161 |
162 | showAdvancedCommands(visible) {
163 | document.getElementById("toggleAdvancedCommands").textContent =
164 | visible ? "Hide advanced commands" : "Show advanced commands";
165 |
166 | // Add/remove the showAdvanced class to show/hide advanced commands.
167 | const addOrRemove = visible ? "add" : "remove";
168 | HelpDialog.dialogElement.classList[addOrRemove]("showAdvanced");
169 | }
170 | };
171 |
172 | UIComponentServer.registerHandler(function(event) {
173 | switch (event.data.name != null ? event.data.name : event.data) {
174 | case "hide": HelpDialog.hide(); break;
175 | case "activate":
176 | HelpDialog.init();
177 | HelpDialog.show(event.data);
178 | Frame.postMessage("registerFrame");
179 | // If we abandoned (see below) in a mode with a HUD indicator, then we have to reinstate it.
180 | Mode.setIndicator();
181 | break;
182 | case "hidden":
183 | // Unregister the frame, so that it's not available for `gf` or link hints.
184 | Frame.postMessage("unregisterFrame");
185 | // Abandon any HUD which might be showing within the help dialog.
186 | HUD.abandon();
187 | break;
188 | }
189 | });
190 |
191 | document.addEventListener("DOMContentLoaded", function() {
192 | DomUtils.injectUserCss(); // Manually inject custom user styles.
193 | });
194 |
195 | window.HelpDialog = HelpDialog;
196 | window.isVimiumHelpDialog = true;
197 |
--------------------------------------------------------------------------------
/tests/unit_tests/utils_test.js:
--------------------------------------------------------------------------------
1 | import "./test_helper.js";
2 | import "../../lib/settings.js";
3 |
4 | context("isUrl", () => {
5 | should("accept valid URLs", () => {
6 | assert.isTrue(Utils.isUrl("www.google.com"));
7 | assert.isTrue(Utils.isUrl("www.bbc.co.uk"));
8 | assert.isTrue(Utils.isUrl("yahoo.com"));
9 | assert.isTrue(Utils.isUrl("nunames.nu"));
10 | assert.isTrue(Utils.isUrl("user:pass@ftp.xyz.com/test"));
11 |
12 | assert.isTrue(Utils.isUrl("localhost/index.html"));
13 | assert.isTrue(Utils.isUrl("127.0.0.1:8192/test.php"));
14 |
15 | // IPv6
16 | assert.isTrue(Utils.isUrl("[::]:9000"));
17 |
18 | // Long TLDs
19 | assert.isTrue(Utils.isUrl("illinois.state.museum"));
20 | assert.isTrue(Utils.isUrl("eqt5g4fuenphqinx.onion"));
21 |
22 | // Internal URLs.
23 | assert.isTrue(Utils.isUrl("moz-extension://c66906b4-3785-4a60-97bc-094a6366017e/pages/options.html"));
24 | });
25 |
26 | should("reject invalid URLs", () => {
27 | assert.isFalse(Utils.isUrl("a.x"));
28 | assert.isFalse(Utils.isUrl("www-domain-tld"));
29 | });
30 | });
31 |
32 | context("convertToUrl", () => {
33 | should("detect and clean up valid URLs", () => {
34 | assert.equal("http://www.google.com/", Utils.convertToUrl("http://www.google.com/"));
35 | assert.equal("http://www.google.com/", Utils.convertToUrl(" http://www.google.com/ "));
36 | assert.equal("http://www.google.com", Utils.convertToUrl("www.google.com"));
37 | assert.equal("http://google.com", Utils.convertToUrl("google.com"));
38 | assert.equal("http://localhost", Utils.convertToUrl("localhost"));
39 | assert.equal("http://xyz.museum", Utils.convertToUrl("xyz.museum"));
40 | assert.equal("chrome://extensions", Utils.convertToUrl("chrome://extensions"));
41 | assert.equal("http://user:pass@ftp.xyz.com/test", Utils.convertToUrl("user:pass@ftp.xyz.com/test"));
42 | assert.equal("http://127.0.0.1", Utils.convertToUrl("127.0.0.1"));
43 | assert.equal("http://127.0.0.1:8080", Utils.convertToUrl("127.0.0.1:8080"));
44 | assert.equal("http://[::]:8080", Utils.convertToUrl("[::]:8080"));
45 | assert.equal("view-source: 0.0.0.0", Utils.convertToUrl("view-source: 0.0.0.0"));
46 | assert.equal("javascript:alert('25 % 20 * 25 ');", Utils.convertToUrl("javascript:alert('25 % 20 * 25%20');"));
47 | });
48 |
49 | should("convert non-URL terms into search queries", () => {
50 | assert.equal("https://www.google.com/search?q=google", Utils.convertToUrl("google"));
51 | assert.equal("https://www.google.com/search?q=go+ogle.com", Utils.convertToUrl("go ogle.com"));
52 | assert.equal("https://www.google.com/search?q=%40twitter", Utils.convertToUrl("@twitter"));
53 | })
54 | });
55 |
56 | context("createSearchUrl", () => {
57 | should("replace %S without encoding", () => {
58 | assert.equal("https://www.github.com/philc/vimium/pulls", Utils.createSearchUrl("vimium/pulls", "https://www.github.com/philc/%S"))
59 | })
60 | });
61 |
62 | context("extractQuery", () => {
63 | should("extract queries from search URLs", () => {
64 | assert.equal("bbc sport 1", Utils.extractQuery("https://www.google.ie/search?q=%s", "https://www.google.ie/search?q=bbc+sport+1"));
65 | assert.equal("bbc sport 2", Utils.extractQuery("http://www.google.ie/search?q=%s", "https://www.google.ie/search?q=bbc+sport+2"));
66 | assert.equal("bbc sport 3", Utils.extractQuery("https://www.google.ie/search?q=%s", "http://www.google.ie/search?q=bbc+sport+3"));
67 | assert.equal("bbc sport 4", Utils.extractQuery("https://www.google.ie/search?q=%s", "http://www.google.ie/search?q=bbc+sport+4&blah"));
68 | })
69 | });
70 |
71 | context("hasChromePrefix", () => {
72 | should("detect chrome prefixes of URLs", () => {
73 | assert.isTrue(Utils.hasChromePrefix("about:foobar"));
74 | assert.isTrue(Utils.hasChromePrefix("view-source:foobar"));
75 | assert.isTrue(Utils.hasChromePrefix("chrome-extension:foobar"));
76 | assert.isTrue(Utils.hasChromePrefix("data:foobar"));
77 | assert.isTrue(Utils.hasChromePrefix("data:"));
78 | assert.isFalse(Utils.hasChromePrefix(""));
79 | assert.isFalse(Utils.hasChromePrefix("about"));
80 | assert.isFalse(Utils.hasChromePrefix("view-source"));
81 | assert.isFalse(Utils.hasChromePrefix("chrome-extension"));
82 | assert.isFalse(Utils.hasChromePrefix("data"));
83 | assert.isFalse(Utils.hasChromePrefix("data :foobar"));
84 | })
85 | });
86 |
87 | context("hasJavascriptPrefix", () => {
88 | should("detect javascript: URLs", () => {
89 | assert.isTrue(Utils.hasJavascriptPrefix("javascript:foobar"));
90 | assert.isFalse(Utils.hasJavascriptPrefix("http:foobar"));
91 | })
92 | });
93 |
94 | context("decodeURIByParts", () => {
95 | should("decode javascript: URLs", () => {
96 | assert.equal("foobar", Utils.decodeURIByParts("foobar"));
97 | assert.equal(" ", Utils.decodeURIByParts("%20"));
98 | assert.equal("25 % 20 25 ", Utils.decodeURIByParts("25 % 20 25%20"));
99 | })
100 | });
101 |
102 | context("isUrl", () => {
103 | should("identify URLs as URLs", () => assert.isTrue(Utils.isUrl("http://www.example.com/blah")));
104 |
105 | should("identify non-URLs and non-URLs", () => assert.isFalse(Utils.isUrl("http://www.example.com/ blah")));
106 | });
107 |
108 | context("Function currying", () => {
109 | should("Curry correctly", () => {
110 | const foo = (a, b) => `${a},${b}`;
111 | assert.equal("1,2", foo.curry()(1,2));
112 | assert.equal("1,2", foo.curry(1)(2));
113 | assert.equal("1,2", foo.curry(1,2)());
114 | });
115 | });
116 |
117 | context("compare versions", () => {
118 | should("compare correctly", () => {
119 | assert.equal(0, Utils.compareVersions("1.40.1", "1.40.1"));
120 | assert.equal(0, Utils.compareVersions("1.40", "1.40.0"));
121 | assert.equal(0, Utils.compareVersions("1.40.0", "1.40"));
122 | assert.equal(-1, Utils.compareVersions("1.40.1", "1.40.2"));
123 | assert.equal(-1, Utils.compareVersions("1.40.1", "1.41"));
124 | assert.equal(-1, Utils.compareVersions("1.40", "1.40.1"));
125 | assert.equal(1, Utils.compareVersions("1.41", "1.40"));
126 | assert.equal(1, Utils.compareVersions("1.41.0", "1.40"));
127 | assert.equal(1, Utils.compareVersions("1.41.1", "1.41"));
128 | });
129 | });
130 |
131 | context("makeIdempotent", () => {
132 |
133 | let func;
134 | let count = 0;
135 |
136 | setup(() => {
137 | count = 0;
138 | func = Utils.makeIdempotent((n) => {
139 | if (n == null)
140 | n = 1;
141 | count += n;
142 | });
143 | });
144 |
145 | should("call a function once", () => {
146 | func();
147 | assert.equal(1, count);
148 | });
149 |
150 | should("call a function once with an argument", () => {
151 | func(2);
152 | assert.equal(2, count);
153 | });
154 |
155 | should("not call a function a second time", () => {
156 | func();
157 | assert.equal(1, count);
158 | });
159 |
160 | should("not call a function a second time", () => {
161 | func();
162 | assert.equal(1, count);
163 | func();
164 | assert.equal(1, count);
165 | });
166 | });
167 |
168 | context("distinctCharacters", () => {
169 | should("eliminate duplicate characters", () => assert.equal("abc", Utils.distinctCharacters("bbabaabbacabbbab")))
170 | });
171 |
172 | context("escapeRegexSpecialCharacters", () => {
173 | should("escape regexp special characters", () => {
174 | const str = "-[]/{}()*+?.^$|";
175 | const regexp = new RegExp(Utils.escapeRegexSpecialCharacters(str));
176 | assert.isTrue(regexp.test(str));
177 | });
178 | });
179 |
180 | context("extractQuery", () => {
181 | should("extract the query terms from a URL", () => {
182 | const url = "https://www.google.ie/search?q=star+wars&foo&bar";
183 | const searchUrl = "https://www.google.ie/search?q=%s";
184 | assert.equal("star wars", Utils.extractQuery(searchUrl, url));
185 | });
186 |
187 | should("require trailing URL components", () => {
188 | const url = "https://www.google.ie/search?q=star+wars&foo&bar";
189 | const searchUrl = "https://www.google.ie/search?q=%s&foobar=x";
190 | assert.equal(null, Utils.extractQuery(searchUrl, url));
191 | });
192 |
193 | should("accept trailing URL components", () => {
194 | const url = "https://www.google.ie/search?q=star+wars&foo&bar&foobar=x";
195 | const searchUrl = "https://www.google.ie/search?q=%s&foobar=x";
196 | assert.equal("star wars", Utils.extractQuery(searchUrl, url));
197 | });
198 | });
199 |
--------------------------------------------------------------------------------
/content_scripts/hud.js:
--------------------------------------------------------------------------------
1 | //
2 | // A heads-up-display (HUD) for showing Vimium page operations.
3 | // Note: you cannot interact with the HUD until document.body is available.
4 | //
5 | const HUD = {
6 | tween: null,
7 | hudUI: null,
8 | findMode: null,
9 | abandon() {
10 | if (this.hudUI)
11 | this.hudUI.hide(false);
12 | },
13 |
14 | pasteListener: null, // Set by @pasteFromClipboard to handle the value returned by pasteResponse
15 |
16 | // This HUD is styled to precisely mimick the chrome HUD on Mac. Use the "has_popup_and_link_hud.html"
17 | // test harness to tweak these styles to match Chrome's. One limitation of our HUD display is that
18 | // it doesn't sit on top of horizontal scrollbars like Chrome's HUD does.
19 |
20 | init(focusable) {
21 | if (focusable == null)
22 | focusable = true;
23 | if (this.hudUI == null) {
24 | this.hudUI = new UIComponent("pages/hud.html", "vimiumHUDFrame", ({data}) => {
25 | if (this[data.name])
26 | return this[data.name](data);
27 | });
28 | }
29 | // this[data.name]? data
30 | if (this.tween == null)
31 | this.tween = new Tween("iframe.vimiumHUDFrame.vimiumUIComponentVisible", this.hudUI.shadowDOM);
32 | if (focusable) {
33 | this.hudUI.toggleIframeElementClasses("vimiumNonClickable", "vimiumClickable");
34 | // Note(gdh1995): Chrome 74 only acknowledges text selection when a frame has been visible. See more in #3277.
35 | // Note(mrmr1993): Show the HUD frame, so Firefox will actually perform the paste.
36 | this.hudUI.toggleIframeElementClasses("vimiumUIComponentHidden", "vimiumUIComponentVisible");
37 | // Force the re-computation of styles, so Chrome sends a visibility change message to the child frame.
38 | // See https://github.com/philc/vimium/pull/3277#issuecomment-487363284
39 | getComputedStyle(this.hudUI.iframeElement).display;
40 | } else {
41 | this.hudUI.toggleIframeElementClasses("vimiumClickable", "vimiumNonClickable");
42 | }
43 | },
44 |
45 |
46 | showForDuration(text, duration) {
47 | this.show(text);
48 | this._showForDurationTimerId = setTimeout((() => this.hide()), duration);
49 | },
50 |
51 | show(text) {
52 | DomUtils.documentComplete(() => {
53 | // @hudUI.activate will take charge of making it visible
54 | this.init(false);
55 | clearTimeout(this._showForDurationTimerId);
56 | this.hudUI.activate({name: "show", text});
57 | this.tween.fade(1.0, 150);
58 | });
59 | },
60 |
61 | showFindMode(findMode = null) {
62 | this.findMode = findMode;
63 | DomUtils.documentComplete(() => {
64 | this.init();
65 | this.hudUI.activate({name: "showFindMode"});
66 | this.tween.fade(1.0, 150);
67 | });
68 | },
69 |
70 | search(data) {
71 | // NOTE(mrmr1993): On Firefox, window.find moves the window focus away from the HUD. We use postFindFocus
72 | // to put it back, so the user can continue typing.
73 | this.findMode.findInPlace(data.query, {"postFindFocus": this.hudUI.iframeElement.contentWindow});
74 |
75 | // Show the number of matches in the HUD UI.
76 | const matchCount = FindMode.query.parsedQuery.length > 0 ? FindMode.query.matchCount : 0;
77 | const showMatchText = FindMode.query.rawQuery.length > 0;
78 | this.hudUI.postMessage({name: "updateMatchesCount", matchCount, showMatchText});
79 | },
80 |
81 | // Hide the HUD.
82 | // If :immediate is falsy, then the HUD is faded out smoothly (otherwise it is hidden immediately).
83 | // If :updateIndicator is truthy, then we also refresh the mode indicator. The only time we don't update the
84 | // mode indicator, is when hide() is called for the mode indicator itself.
85 | hide(immediate, updateIndicator) {
86 | if (immediate == null)
87 | immediate = false;
88 | if (updateIndicator == null)
89 | updateIndicator = true;
90 | if ((this.hudUI != null) && (this.tween != null)) {
91 | clearTimeout(this._showForDurationTimerId);
92 | this.tween.stop();
93 | if (immediate) {
94 | if (updateIndicator)
95 | Mode.setIndicator();
96 | else
97 | this.hudUI.hide();
98 | } else {
99 | this.tween.fade(0, 150, () => this.hide(true, updateIndicator));
100 | }
101 | }
102 | },
103 |
104 | // These parameters describe the reason find mode is exiting, and come from the HUD UI component.
105 | hideFindMode({exitEventIsEnter, exitEventIsEscape}) {
106 | let postExit;
107 | this.findMode.checkReturnToViewPort();
108 |
109 | // An element won't receive a focus event if the search landed on it while we were in the HUD iframe. To
110 | // end up with the correct modes active, we create a focus/blur event manually after refocusing this
111 | // window.
112 | window.focus();
113 |
114 | const focusNode = DomUtils.getSelectionFocusElement();
115 | if (document.activeElement != null)
116 | document.activeElement.blur();
117 |
118 | if (focusNode && focusNode.focus)
119 | focusNode.focus();
120 |
121 | if (exitEventIsEnter) {
122 | FindMode.handleEnter();
123 | if (FindMode.query.hasResults)
124 | postExit = () => newPostFindMode();
125 | } else if (exitEventIsEscape) {
126 | // We don't want FindMode to handle the click events that FindMode.handleEscape can generate, so we
127 | // wait until the mode is closed before running it.
128 | postExit = FindMode.handleEscape;
129 | }
130 |
131 | this.findMode.exit();
132 | if (postExit)
133 | postExit();
134 | },
135 |
136 | // These commands manage copying and pasting from the clipboard in the HUD frame.
137 | // NOTE(mrmr1993): We need this to copy and paste on Firefox:
138 | // * an element can't be focused in the background page, so copying/pasting doesn't work
139 | // * we don't want to disrupt the focus in the page, in case the page is listening for focus/blur events.
140 | // * the HUD shouldn't be active for this frame while any of the copy/paste commands are running.
141 | copyToClipboard(text) {
142 | DomUtils.documentComplete(() => {
143 | this.init();
144 | this.hudUI.postMessage({name: "copyToClipboard", data: text});
145 | });
146 | },
147 |
148 | pasteFromClipboard(pasteListener) {
149 | this.pasteListener = pasteListener;
150 | DomUtils.documentComplete(() => {
151 | this.init();
152 | this.tween.fade(0, 0);
153 | this.hudUI.postMessage({name: "pasteFromClipboard"});
154 | });
155 | },
156 |
157 | pasteResponse({data}) {
158 | // Hide the HUD frame again.
159 | this.hudUI.toggleIframeElementClasses("vimiumUIComponentVisible", "vimiumUIComponentHidden");
160 | this.unfocusIfFocused();
161 | this.pasteListener(data);
162 | },
163 |
164 | unfocusIfFocused() {
165 | // On Firefox, if an