├── 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 | IFrame test harness 6 | 21 | 22 | 23 |

IFrame test page

24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /pages/exclusions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
PatternsKeys
7 | 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: File a bug 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | Include a clear bug description. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to URL '...' 16 | 2. Click on '....' 17 | 18 | Include a screenshot if applicable. 19 | 20 | **Browser and Vimium version** 21 | 22 | If you're using Chrome, include the Chrome and OS version found at chrome://version. Also include the Vimium version found at chrome://extensions. 23 | 24 | If you're using Firefox, report the Firefox and OS version found at about:support. Also include the Vimium version found at about:addons. 25 | -------------------------------------------------------------------------------- /tests/dom_tests/test_runner.js: -------------------------------------------------------------------------------- 1 | Tests.outputMethod = function(...args) { 2 | let newOutput = args.join("\n"); 3 | // Escape html 4 | newOutput = newOutput.replace(/&/g, "&").replace(//g, ">"); 5 | // Highlight the source of the error 6 | newOutput = newOutput.replace(/\/([^:/]+):([0-9]+):([0-9]+)/, "/$1:$2:$3"); 7 | document.getElementById("output-div").innerHTML += "
" + newOutput + "
"; 8 | console.log.apply(console, args); 9 | }; 10 | 11 | // Puppeteer will call the tests manually 12 | if (!navigator.userAgent.includes("HeadlessChrome")) { 13 | console.log("we're not in headless chrome"); 14 | // ensure the extension has time to load before commencing the tests 15 | document.addEventListener("DOMContentLoaded", () => setTimeout(Tests.run, 200)); 16 | } 17 | -------------------------------------------------------------------------------- /pages/logging.js: -------------------------------------------------------------------------------- 1 | const $ = id => document.getElementById(id); 2 | 3 | document.addEventListener("DOMContentLoaded", function() { 4 | DomUtils.injectUserCss(); // Manually inject custom user styles. 5 | $("vimiumVersion").innerText = Utils.getCurrentVersion(); 6 | 7 | chrome.storage.local.get("installDate", items => $("installDate").innerText = items.installDate.toString()); 8 | 9 | const branchRefRequest = new XMLHttpRequest(); 10 | branchRefRequest.addEventListener("load", function() { 11 | const branchRefParts = branchRefRequest.responseText.split("refs/heads/", 2); 12 | if (branchRefParts.length === 2) 13 | $("branchRef").innerText = branchRefParts[1]; 14 | else 15 | $("branchRef").innerText = `HEAD detatched at ${branchRefParts[0]}`; 16 | $("branchRef-wrapper").classList.add("no-hide"); 17 | }); 18 | branchRefRequest.open("GET", chrome.extension.getURL(".git/HEAD")); 19 | branchRefRequest.send(); 20 | }); 21 | -------------------------------------------------------------------------------- /pages/hud.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | HUD 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /pages/vomnibar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Vomnibar 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 | 19 |
20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /MIT-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Phil Crosby, Ilya Sukhar. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /test_harnesses/form.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | Page with forms 6 | 7 | 8 | 9 |
10 |

11 | Text: 12 | Text 2: 13 | Email: 14 | No Type: 15 |

16 |

17 | Search: 18 | Search 2: 19 |

20 |

21 | Radio:
22 | Maryland
23 | California 24 |

25 |

26 | 30 |

31 |

32 | Button: 33 |

34 |

35 | Submit: 36 |

37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /tests/dom_tests/chrome.js: -------------------------------------------------------------------------------- 1 | // 2 | // Mock the Chrome extension API. 3 | // 4 | 5 | window.chromeMessages = []; 6 | 7 | document.hasFocus = () => true; 8 | 9 | window.forTrusted = handler => handler; 10 | 11 | const fakeManifest = { 12 | version: "1.51" 13 | }; 14 | 15 | window.chrome = { 16 | runtime: { 17 | connect() { 18 | return { 19 | onMessage: { 20 | addListener() {} 21 | }, 22 | onDisconnect: { 23 | addListener() {} 24 | }, 25 | postMessage() {} 26 | }; 27 | }, 28 | onMessage: { 29 | addListener() {} 30 | }, 31 | sendMessage(message) { return chromeMessages.unshift(message); }, 32 | getManifest() { return fakeManifest; }, 33 | getURL(url) { return `../../${url}`; } 34 | }, 35 | storage: { 36 | local: { 37 | get() {}, 38 | set() {} 39 | }, 40 | sync: { 41 | get(_, callback) { return callback ? callback({}) : null; }, 42 | set() {} 43 | }, 44 | onChanged: { 45 | addListener() {} 46 | } 47 | }, 48 | extension: { 49 | inIncognitoContext: false, 50 | getURL(url) { return chrome.runtime.getURL(url); } 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /test_harnesses/page_with_links.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | Page with many links 6 | 24 | 25 | 26 | This will be a link spanning two
lines
27 | 28 |

29 |

30 | 31 | This link has a lot of vertical padding 32 | 33 |

34 |

35 |

36 |

37 | 38 | This link has a lot of vertical padding on the top 39 | 40 |

41 |

42 |
div with an onclick attribute
43 | 44 | 45 |

46 |

47 | An anchor with just a name 48 | 49 | 50 | -------------------------------------------------------------------------------- /lib/clipboard.js: -------------------------------------------------------------------------------- 1 | const Clipboard = { 2 | _createTextArea(tagName) { 3 | if (tagName == null) { tagName = "textarea"; } 4 | const textArea = document.createElement(tagName); 5 | textArea.style.position = "absolute"; 6 | textArea.style.left = "-100%"; 7 | textArea.contentEditable = "true"; 8 | return textArea; 9 | }, 10 | 11 | // http://groups.google.com/group/chromium-extensions/browse_thread/thread/49027e7f3b04f68/f6ab2457dee5bf55 12 | copy({data}) { 13 | const textArea = this._createTextArea(); 14 | textArea.value = data.replace(/\xa0/g, " "); 15 | 16 | document.body.appendChild(textArea); 17 | textArea.select(); 18 | document.execCommand("Copy"); 19 | document.body.removeChild(textArea); 20 | }, 21 | 22 | // Returns a string representing the clipboard contents. Supports rich text clipboard values. 23 | paste() { 24 | const textArea = this._createTextArea("div"); // Use a
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 |
Vimium has been updated to 1.14. 76 | See the changes. 77 | x
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 |
Vimium Log
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 |
Vimium Search Completion
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 | \ 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 | \ 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 |
41 | test 42 |
\ 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 |
50 | test 51 |
\ 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 |
113 | test 114 |
\ 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); 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 | 46 | 51 | 52 |
44 | Vimium 45 | 47 | Options 48 | Wiki 49 | × 50 |
53 |
54 |
55 |
56 | 57 | 58 | 59 | 60 | 61 | 62 |
Navigating the page
63 |
64 |
65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
Using the vomnibar
Using find
Navigating history
Manipulating tabs
Miscellaneous
87 |
88 | 89 |
90 | 91 | 92 | 95 | 98 | 99 |
93 | 94 |
100 |
101 | 102 |
103 |
104 | 105 |
106 |
107 | Enjoying Vimium? 108 | Leave us 110 | feedback.
111 | Found a bug? Report it here. 112 |
113 |
114 | Version
115 | What's new? 116 |
117 |
118 |
119 |
120 | 121 | 122 | 129 | 130 | 135 | 136 | 137 | 138 | 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 | 140 | 141 |
Node/Test
142 | 143 |
144 | test 145 | 146 | 147 | 148 |
149 | test 150 |
151 | 152 | 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