diff options
61 files changed, 1696 insertions, 1239 deletions
| diff --git a/.gitmodules b/.gitmodules index 3f5b74a2..24458233 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@  [submodule "tests/shoulda.js"]  	path = tests/shoulda.js -	url = git://github.com/philc/shoulda.js.git +	url = https://github.com/philc/shoulda.js.git diff --git a/.travis.yml b/.travis.yml index 4f9de03b..81d73211 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,13 @@ +sudo: false  language: node_js -node_js: 0.12 +node_js: "8"  before_install: -  - "npm install -g coffee-script" -  - "npm install path@0.11" -  - "npm install util" -  - "cake build" -script: "cake test" +  - npm install -g coffee-script +  - npm install path@0.11 +  - npm install util +  - npm install -g phantomjs@1.9.20 +  - cake build +script: cake test  notifications:    email: false  branches: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 27db315c..e4ed8b8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@  You'd like to fix a bug or implement a feature? Great! Check out the bugs on our issues tracker, or implement  one of the suggestions there that have been tagged "help wanted". If you have a suggestion of your own, start  a discussion on the issues tracker or on the -[mailing list](http://groups.google.com/group/vimium-dev?hl=en). If it mirrors a similar feature in another +[mailing list](https://groups.google.com/group/vimium-dev?hl=en). If it mirrors a similar feature in another  browser or in Vim itself, let us know. Once you've picked something to work on, add a comment to the  respective issue so others don't duplicate your effort. @@ -11,9 +11,16 @@ respective issue so others don't duplicate your effort.  Please include the following when reporting an issue: - 1. Chrome and OS Version: `chrome://version` +### Chrome/Chromium + + 1. Chrome/Chromium and OS Version: `chrome://version`   1. Vimium Version: `chrome://extensions` +### Firefox + + 1. Firefox and OS Version: `about:` + 1. Vimium Version: `about:addons`, then click on `More` below Vimium +  ## Installing From Source  Vimium is written in Coffeescript, which compiles to Javascript. To @@ -37,7 +44,7 @@ Our tests use [shoulda.js](https://github.com/philc/shoulda.js) and [PhantomJS](   1. `git submodule update --init --recursive` -- this pulls in shoulda.js.   1. Install [PhantomJS](http://phantomjs.org/download.html). - 1. `npm install path@0.11` to install the [Node.js Path module](http://nodejs.org/api/path.html), used by the test runner. + 1. `npm install path@0.11` to install the [Node.js Path module](https://nodejs.org/api/path.html), used by the test runner.   1. `npm install util` to install the [util module](https://www.npmjs.com/package/util), used by the tests.   1. `cake build` to compile `*.coffee` to `*.js`   1. `cake test` to run the tests. @@ -47,7 +54,7 @@ Our tests use [shoulda.js](https://github.com/philc/shoulda.js) and [PhantomJS](  You can find out which portions of code need them by looking at our coverage reports. To generate these  reports: - 1. Download [JSCoverage](http://siliconforks.com/jscoverage/download.html) or `brew install jscoverage` + 1. Download [JSCoverage](https://siliconforks.com/jscoverage/download.html) or `brew install jscoverage`   1. `npm install temp`   1. `cake coverage` will generate a coverage report in the form of a JSON file (`jscoverage.json`), which can      then be viewed using [jscoverage-report](https://github.com/int3/jscoverage-report).  See @@ -75,8 +75,12 @@ task "package", "Builds a zip file for submission to the Chrome store. The outpu      blacklist.map((item) -> ["--exclude", "#{item}"]))    spawn "rsync", rsyncOptions, false, true +  spawn "sed", "-i /clipboardWrite/d dist/vimium/manifest.json".split /\s+/    spawn "zip", ["-r", "dist/vimium-#{vimium_version}.zip", "dist/vimium"], false, true +  spawn "zip", "-r -FS dist/vimium-ff-#{vimium_version}.zip background_scripts Cakefile content_scripts CONTRIBUTING.md CREDITS icons lib +                manifest.json MIT-LICENSE.txt pages README.md -x *.coffee -x Cakefile -x CREDITS -x *.md".split(/\s+/), false, true +  # This builds a CRX that's distributable outside of the Chrome web store. Is this used by folks who fork  # Vimium and want to distribute their fork?  task "package-custom-crx", "build .crx file", -> @@ -86,7 +90,11 @@ task "package-custom-crx", "build .crx file", ->    # ugly hack to modify our manifest file on-the-fly    origManifestText = fs.readFileSync "manifest.json"    manifest = JSON.parse origManifestText -  manifest.update_url = "http://philc.github.com/vimium/updates.xml" +  # Update manifest fields that you would like to override here.  If +  # distributing your CRX outside the Chrome webstore in a fork, please follow +  # the instructions available at +  # https://developer.chrome.com/extensions/autoupdate. +  # manifest.update_url = "http://philc.github.com/vimium/updates.xml"    fs.writeFileSync "manifest.json", JSON.stringify manifest    pem = process.env.VIMIUM_CRX_PEM ? "vimium.pem" @@ -1,7 +1,7 @@  Vimium - The Hacker's Browser  ============================= -[](https://travis-ci.org/philc/vimium) +[](https://travis-ci.org/philc/vimium)  Vimium is a Chrome extension that provides keyboard-based navigation and control of the web in the spirit of  the Vim editor. @@ -12,7 +12,7 @@ You can install the stable version of Vimium from the  [Chrome Extensions Gallery](https://chrome.google.com/extensions/detail/dbepggeogbaibhgnhhndojpepiihcmeb).  Please see -[CONTRIBUTING.md](https://github.com/philc/vimium/blob/master/CONTRIBUTING.md#installing-from-source) +[CONTRIBUTING.md](CONTRIBUTING.md#installing-from-source)  for instructions on how you can install Vimium from source.  The Options page can be reached via a link on the help dialog (type `?`) or via the button next to Vimium on @@ -22,7 +22,7 @@ Keyboard Bindings  -----------------  Modifier keys are specified as `<c-x>`, `<m-x>`, and `<a-x>` for ctrl+x, meta+x, and alt+x -respectively. See the next section for how to customize these bindings. +respectively. For shift+x and ctrl-shift-x, just type `X` and `<c-X>`. See the next section for how to customize these bindings.  Once you have Vimium installed, you can see this list of key bindings at any time by typing `?`. @@ -80,6 +80,7 @@ Manipulating tabs:      x       close current tab      X       restore closed tab (i.e. unwind the 'x' command)      T       search through your open tabs +    W       move current tab to new window      <a-p>   pin/unpin current tab  Using marks: @@ -140,29 +141,89 @@ The following special keys are available for mapping:  - `<c-*>`, `<a-*>`, `<m-*>` for ctrl, alt, and meta (command on Mac) respectively with any key. Replace `*`    with the key of choice. -- `<left>`, `<right>`, `<up>`, `<down>` for the arrow keys -- `<space>` and `<backspace>` for the space and backspace keys -- `<f1>` through `<f12>` for the function keys +- `<left>`, `<right>`, `<up>`, `<down>` for the arrow keys. +- `<f1>` through `<f12>` for the function keys. +- `<space>` for the space key. +- `<tab>`, `<enter>`, `<delete>`, `<backspace>`, `<insert>`, `<home>` and `<end>` for the corresponding non-printable keys (version 1.62 onwards).  Shifts are automatically detected so, for example, `<c-&>` corresponds to ctrl+shift+7 on an English keyboard.  More documentation  ------------------  Many of the more advanced or involved features are documented on -[Vimium's github wiki](https://github.com/philc/vimium/wiki). Also +[Vimium's GitHub wiki](https://github.com/philc/vimium/wiki). Also  see the [FAQ](https://github.com/philc/vimium/wiki/FAQ).  Contributing  ------------ -Please see [CONTRIBUTING.md](https://github.com/philc/vimium/blob/master/CONTRIBUTING.md) for details. +Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. + +Firefox Support +--------------- + +There is an *experimental* port of Vimium on Firefox [here](https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/). +This is very much experimental: most features work, although some bugs and issues remain. + +PRs are welcome.  Release Notes  ------------- -Changes since the previous release (not yet in the Chrome Store version) +1.62 (2017-12-09) + +- Backup and restore Vimium options (see the very bottom of the options page, below *Advanced Options*). +- It is now possible to map `<tab>`, `<enter>`, `<delete>`, `<insert>`, `<home>` and `<end>`. +- New command options for `createTab` to create new normal and incognito windows +  ([examples](https://github.com/philc/vimium/wiki/Tips-and-Tricks#creating-tabs-with-urls-and-windows)). +- Firefox only: +    - Fix copy and paste commands. +    - When upgrading, you will be asked to re-validate permissions.  The only +      new permission is "copy and paste to/from clipboard" (the +      `clipboardWrite` permission).  This is necessary to support copy/paste on +      Firefox. +- Various bug fixes. + +1.61 (2017-10-27) + +- For *filtered hints*, you can now use alphabetical hint characters +  instead of digits; use `<Shift>` for hint characters. +- With `map R reload hard`, the reload command now asks Chrome to bypass its cache. +- You can now map `<c-[>` to a command (in which case it will not be treated as `Escape`). +- Various bug fixes, particularly for Firefox. +- Minor versions: +    - 1.61.1: Fix `map R reload hard`. + +1.60 (2017-09-14) + +- Features: +    - There's a new (advanced) option to ignore the keyboard layout; this can +      be helpful for users of non-Latin keyboards. +    - Firefox support.  This is a work in progress; please report any issues +      [here](https://github.com/philc/vimium/issues?q=is%3Aopen+sort%3Aupdated-desc); +      see the [add +      on](https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/). + +- Bug fixes: +    - Fixed issue affecting hint placement when the display is zoomed. +    - Fixed search completion for Firefox (released as 1.59.1, Firefox only). + +- Minor versions: +    - 1.60.1: fix [#2642](https://github.com/philc/vimium/issues/2642). +    - 1.60.2: revert previous fix for HiDPI screens.  This was breaking link-hint positioning for some users. +    - 1.60.3: [fix](https://github.com/philc/vimium/pull/2649) link-hint positioning. +    - 1.60.4: [fix](https://github.com/philc/vimium/pull/2602) hints opening in new tab (Firefox only). + +1.59 (2017-04-07) + +- Features: +    - Some commands now work on PDF tabs (`J`, `K`, `o`, `b`, etc.). Scrolling and other content-related commands still do not work. + +1.58 (2017-03-08)  - Features:      - The `createTab` command can now open specific URLs (e.g, `map X createTab http://www.bbc.com/news`). +    - With pass keys defined for a site (such as GMail), you can now use Vimium's bindings again with, for example, `map \ passNextKey normal`; +      this reactivates normal mode temporarily, but *without any pass keys*.      - You can now map multi-modifier keys, for example: `<c-a-X>`.      - Vimium can now do simple key mapping in some modes; see        [here](https://github.com/philc/vimium/wiki/Tips-and-Tricks#key-mapping). @@ -172,6 +233,10 @@ Changes since the previous release (not yet in the Chrome Store version)  - Process:      - In order to provide faster bug fixes, we may in future push new releases without the noisy notification. +- Post-release minor fixes: +    - 1.58.1 (2017-03-09) fix bug in `LinkHints.activateModeWithQueue` (#2445). +    - 1.58.2 (2017-03-19) fix key handling bug (#2453). +  1.57 (2016-10-01)  - New commands: @@ -435,7 +500,7 @@ Changes since the previous release (not yet in the Chrome Store version)  -  Arrow keys and function keys can now be mapped using <left>, <right>, <up>, <down>,     <f1>, <f2>, etc. in the mappings interface.  -  There is a new command `goUp` (mapped to `gu` by default) that will go up one level in the URL hierarchy. -   For example: from http://vimium.github.com/foo/bar to http://vimium.github.com/foo. At the moment, `goUp` +   For example: from https://vimium.github.io/foo/bar to https://vimium.github.io/foo. At the moment, `goUp`  does not support command repetition.  -  Bug fixes and optimizations. diff --git a/background_scripts/bg_utils.coffee b/background_scripts/bg_utils.coffee index b8e618ff..698f5352 100644 --- a/background_scripts/bg_utils.coffee +++ b/background_scripts/bg_utils.coffee @@ -18,7 +18,7 @@ class TabRecency        @deregister removedTabId        @register addedTabId -    chrome.windows.onFocusChanged.addListener (wnd) => +    chrome.windows?.onFocusChanged.addListener (wnd) =>        if wnd != chrome.windows.WINDOW_ID_NONE          chrome.tabs.query {windowId: wnd, active: true}, (tabs) =>            @register tabs[0].id if tabs[0] diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index 8d3808a2..4d2e1606 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -9,7 +9,6 @@ Commands =      Settings.postUpdateHooks["keyMappings"] = @loadKeyMappings.bind this      @loadKeyMappings Settings.get "keyMappings" -    @prepareHelpPageData()    loadKeyMappings: (customKeyMappings) ->      @keyToCommandRegistry = {} @@ -43,6 +42,7 @@ Commands =      chrome.storage.local.set mapKeyRegistry: @mapKeyRegistry      @installKeyStateMapping() +    @prepareHelpPageData()      # Push the key mapping for passNextKey into Settings so that it's available in the front end for insert      # mode.  We exclude single-key mappings (that is, printable keys) because when users press printable keys @@ -113,6 +113,9 @@ Commands =            # We don't need these properties in the content scripts.            delete currentMapping[key][prop] for prop in ["keySequence", "description"]      chrome.storage.local.set normalModeKeyStateMapping: keyStateMapping +    # Inform `KeyboardUtils.isEscape()` whether `<c-[>` should be interpreted as `Escape` (which it is by +    # default). +    chrome.storage.local.set useVimLikeEscape: "<c-[>" not of keyStateMapping    # Build the "helpPageData" data structure which the help page needs and place it in Chrome storage.    prepareHelpPageData: -> @@ -337,8 +340,8 @@ commandDescriptions =    toggleViewSource: ["View page source", { noRepeat: true }]    copyCurrentUrl: ["Copy the current URL to the clipboard", { noRepeat: true }] -  openCopiedUrlInCurrentTab: ["Open the clipboard's URL in the current tab", { background: true, noRepeat: true }] -  openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true, repeatLimit: 20 }] +  openCopiedUrlInCurrentTab: ["Open the clipboard's URL in the current tab", { noRepeat: true }] +  openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { repeatLimit: 20 }]    enterInsertMode: ["Enter insert mode", { noRepeat: true }]    passNextKey: ["Pass the next key to the page"] diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index 66ad2e38..987ba8a4 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -763,6 +763,8 @@ HistoryCache =      return @callbacks.push(callback) if @callbacks      @callbacks = [callback]      chrome.history.search { text: "", maxResults: @size, startTime: 0 }, (history) => +      # On Firefox, some history entries do not have titles. +      history.map (entry) -> entry.title ?= ""        history.sort @compareHistoryByUrl        @history = history        chrome.history.onVisited.addListener(@onPageVisited.bind(this)) @@ -778,6 +780,8 @@ HistoryCache =    # When a page we've seen before has been visited again, be sure to replace our History item so it has the    # correct "lastVisitTime". That's crucial for ranking Vomnibar suggestions.    onPageVisited: (newPage) -> +    # On Firefox, some history entries do not have titles. +    newPage.title ?= ""      i = HistoryCache.binarySearch(newPage, @history, @compareHistoryByUrl)      pageWasFound = (@history[i]?.url == newPage.url)      if pageWasFound diff --git a/background_scripts/completion_engines.coffee b/background_scripts/completion_engines.coffee index 5ec24ed6..a6ff6dc3 100644 --- a/background_scripts/completion_engines.coffee +++ b/background_scripts/completion_engines.coffee @@ -47,18 +47,18 @@ class GoogleXMLBaseEngine extends BaseEngine  class Google extends GoogleXMLBaseEngine    constructor: () ->      super -      engineUrl: "http://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=%s" -      regexps: "^https?://[a-z]+\\.google\\.(com|ie|co\\.uk|ca|com\\.au)/" +      engineUrl: "https://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=%s" +      regexps: "^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/"        example: -        searchUrl: "http://www.google.com/search?q=%s" +        searchUrl: "https://www.google.com/search?q=%s"          keyword: "g"  class GoogleMaps extends GoogleXMLBaseEngine    prefix: "map of "    constructor: () ->      super -      engineUrl: "http://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=#{@prefix.split(' ').join '+'}%s" -      regexps: "^https?://[a-z]+\\.google\\.(com|ie|co\\.uk|ca|com\\.au)/maps" +      engineUrl: "https://suggestqueries.google.com/complete/search?ss_protocol=legace&client=toolbar&q=#{@prefix.split(' ').join '+'}%s" +      regexps: "^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/maps"        example:          searchUrl: "https://www.google.com/maps?q=%s"          keyword: "m" @@ -77,10 +77,10 @@ class GoogleMaps extends GoogleXMLBaseEngine  class Youtube extends GoogleXMLBaseEngine    constructor: ->      super -      engineUrl: "http://suggestqueries.google.com/complete/search?client=youtube&ds=yt&xml=t&q=%s" +      engineUrl: "https://suggestqueries.google.com/complete/search?client=youtube&ds=yt&xml=t&q=%s"        regexps: "^https?://[a-z]+\\.youtube\\.com/results"        example: -        searchUrl: "http://www.youtube.com/results?search_query=%s" +        searchUrl: "https://www.youtube.com/results?search_query=%s"          keyword: "y"  class Wikipedia extends BaseEngine @@ -89,7 +89,7 @@ class Wikipedia extends BaseEngine        engineUrl: "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=%s"        regexps: "^https?://[a-z]+\\.wikipedia\\.org/"        example: -        searchUrl: "http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s" +        searchUrl: "https://www.wikipedia.org/w/index.php?title=Special:Search&search=%s"          keyword: "w"    parse: (xhr) -> JSON.parse(xhr.responseText)[1] @@ -97,7 +97,7 @@ class Wikipedia extends BaseEngine  class Bing extends BaseEngine    constructor: ->      super -      engineUrl: "http://api.bing.com/osjson.aspx?query=%s" +      engineUrl: "https://api.bing.com/osjson.aspx?query=%s"        regexps: "^https?://www\\.bing\\.com/search"        example:          searchUrl: "https://www.bing.com/search?q=%s" @@ -111,11 +111,22 @@ class Amazon extends BaseEngine        engineUrl: "https://completion.amazon.com/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=1&q=%s"        regexps: "^https?://www\\.amazon\\.(com|co\\.uk|ca|de|com\\.au)/s/"        example: -        searchUrl: "http://www.amazon.com/s/?field-keywords=%s" +        searchUrl: "https://www.amazon.com/s/?field-keywords=%s"          keyword: "a"    parse: (xhr) -> JSON.parse(xhr.responseText)[1] +class AmazonJapan extends BaseEngine +  constructor: -> +    super +      engineUrl: "https://completion.amazon.co.jp/search/complete?method=completion&search-alias=aps&client=amazon-search-ui&mkt=6&q=%s" +      regexps: "^https?://www\\.amazon\\.co\\.jp/(s/|gp/search)" +      example: +        searchUrl: "https://www.amazon.co.jp/s/?field-keywords=%s" +        keyword: "aj" + +  parse: (xhr) -> JSON.parse(xhr.responseText)[1] +  class DuckDuckGo extends BaseEngine    constructor: ->      super @@ -131,10 +142,10 @@ class DuckDuckGo extends BaseEngine  class Webster extends BaseEngine    constructor: ->      super -      engineUrl: "http://www.merriam-webster.com/autocomplete?query=%s" +      engineUrl: "https://www.merriam-webster.com/autocomplete?query=%s"        regexps: "^https?://www.merriam-webster.com/dictionary/"        example: -        searchUrl: "http://www.merriam-webster.com/dictionary/%s" +        searchUrl: "https://www.merriam-webster.com/dictionary/%s"          keyword: "dw"          description: "Dictionary" @@ -152,6 +163,17 @@ class Qwant extends BaseEngine    parse: (xhr) ->      suggestion.value for suggestion in JSON.parse(xhr.responseText).data.items +class UpToDate extends BaseEngine +  constructor: -> +    super +      engineUrl: "https://www.uptodate.com/services/app/contents/search/autocomplete/json?term=%s&limit=10" +      regexps: "^https?://www\\.uptodate\\.com/" +      example: +        searchUrl: "https://www.uptodate.com/contents/search?search=%s&searchType=PLAIN_TEXT&source=USER_INPUT&searchControl=TOP_PULLDOWN&autoComplete=false" +        keyword: "upto" + +  parse: (xhr) -> JSON.parse(xhr.responseText).data.searchTerms +  # A dummy search engine which is guaranteed to match any search URL, but never produces completions.  This  # allows the rest of the logic to be written knowing that there will always be a completion engine match.  class DummyCompletionEngine extends BaseEngine @@ -169,8 +191,10 @@ CompletionEngines = [    Wikipedia    Bing    Amazon +  AmazonJapan    Webster    Qwant +  UpToDate    DummyCompletionEngine  ] diff --git a/background_scripts/completion_search.coffee b/background_scripts/completion_search.coffee index 7926b45b..52206c78 100644 --- a/background_scripts/completion_search.coffee +++ b/background_scripts/completion_search.coffee @@ -2,7 +2,7 @@  # This is a wrapper class for completion engines.  It handles the case where a custom search engine includes a  # prefix query term (or terms).  For example:  # -#   http://www.google.com/search?q=javascript+%s +#   https://www.google.com/search?q=javascript+%s  #  # In this case, we get better suggestions if we include the term "javascript" in queries sent to the  # completion engine.  This wrapper handles adding such prefixes to completion-engine queries and removing them diff --git a/background_scripts/exclusions.coffee b/background_scripts/exclusions.coffee index 42d3b872..fec66f4c 100644 --- a/background_scripts/exclusions.coffee +++ b/background_scripts/exclusions.coffee @@ -20,12 +20,12 @@ Exclusions =    # Make RegexpCache, which is required on the page popup, accessible via the Exclusions object.    RegexpCache: RegexpCache -  rules: Settings.get("exclusionRules") +  rules: Settings.get "exclusionRules"    # Merge the matching rules for URL, or null.  In the normal case, we use the configured @rules; hence, this    # is the default.  However, when called from the page popup, we are testing what effect candidate new rules    # would have on the current tab.  In this case, the candidate rules are provided by the caller. -  getRule: (url, rules=@rules) -> +  getRule: (url, rules = @rules) ->      matchingRules = (rule for rule in rules when rule.pattern and 0 <= url.search RegexpCache.get rule.pattern)      # An absolute exclusion rule (one with no passKeys) takes priority.      for rule in matchingRules @@ -47,7 +47,10 @@ Exclusions =      @rules = rules.filter (rule) -> rule and rule.pattern      Settings.set "exclusionRules", @rules -  postUpdateHook: (@rules) -> +  postUpdateHook: (rules) -> +    # NOTE(mrmr1993): In FF, the |rules| argument will be garbage collected when the exclusions popup is +    # closed. Do NOT store it/use it asynchronously. +    @rules = Settings.get "exclusionRules"      RegexpCache.clear()  # Register postUpdateHook for exclusionRules setting. diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index 72f87a7e..e3188a26 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -5,6 +5,7 @@ root = exports ? window  chrome.runtime.onInstalled.addListener ({ reason }) ->    # See https://developer.chrome.com/extensions/runtime#event-onInstalled    return if reason in [ "chrome_update", "shared_module_update" ] +  return if Utils.isFirefox()    manifest = chrome.runtime.getManifest()    # Content scripts loaded on every page should be in the same group. We assume it is the first.    contentScripts = manifest.content_scripts[0] @@ -19,7 +20,7 @@ chrome.runtime.onInstalled.addListener ({ reason }) ->            func tab.id, { file: file, allFrames: contentScripts.all_frames }, checkLastRuntimeError  frameIdsForTab = {} -portsForTab = {} +root.portsForTab = {}  root.urlForTab = {}  # This is exported for use by "marks.coffee". @@ -50,6 +51,10 @@ completers =  completionHandlers =    filter: (completer, request, port) ->      completer.filter request, (response) -> +      # NOTE(smblott): response contains `relevancyFunction` (function) properties which cause postMessage, +      # below, to fail in Firefox. See #2576.  We cannot simply delete these methods, as they're needed +      # elsewhere.  Converting the response to JSON and back is a quick and easy way to sanitize the object. +      response = JSON.parse JSON.stringify response        # We use try here because this may fail if the sender has already navigated away from the original page.        # This can happen, for example, when posting completion suggestions from the SearchEngineCompleter        # (which is done asynchronously). @@ -99,10 +104,27 @@ TabOperations =      tabConfig =        url: Utils.convertToUrl request.url        index: request.tab.index + 1 -      selected: true +      active: true        windowId: request.tab.windowId -      openerTabId: request.tab.id -    chrome.tabs.create tabConfig, callback +    tabConfig.active = request.active if request.active? +    # Firefox does not support "about:newtab" in chrome.tabs.create. +    delete tabConfig["url"] if tabConfig["url"] == Settings.defaults.newTabUrl + +    # Firefox <57 throws an error when openerTabId is used (issue 1238314). +    canUseOpenerTabId = not (Utils.isFirefox() and Utils.compareVersions(Utils.firefoxVersion(), "57") < 0) +    tabConfig.openerTabId = request.tab.id if canUseOpenerTabId + +    chrome.tabs.create tabConfig, -> callback request + +  # Opens request.url in new window and switches to it. +  openUrlInNewWindow: (request, callback = (->)) -> +    winConfig = +      url: Utils.convertToUrl request.url +      active: true +    winConfig.active = request.active if request.active? +    # Firefox does not support "about:newtab" in chrome.tabs.create. +    delete tabConfig["url"] if tabConfig["url"] == Settings.defaults.newTabUrl +    chrome.windows.create winConfig, callback  toggleMuteTab = do ->    muteTab = (tab) -> chrome.tabs.update tab.id, {muted: !tab.mutedInfo.muted} @@ -126,12 +148,12 @@ toggleMuteTab = do ->  #  selectSpecificTab = (request) ->    chrome.tabs.get(request.id, (tab) -> -    chrome.windows.update(tab.windowId, { focused: true }) -    chrome.tabs.update(request.id, { selected: true })) +    chrome.windows?.update(tab.windowId, { focused: true }) +    chrome.tabs.update(request.id, { active: true }))  moveTab = ({count, tab, registryEntry}) ->    count = -count if registryEntry.command == "moveTabLeft" -  chrome.tabs.getAllInWindow null, (tabs) -> +  chrome.tabs.query { currentWindow: true }, (tabs) ->      pinnedCount = (tabs.filter (tab) -> tab.pinned).length      minIndex = if tab.pinned then 0 else pinnedCount      maxIndex = (if tab.pinned then pinnedCount else tabs.length) - 1 @@ -166,13 +188,20 @@ BackgroundCommands =              [if request.tab.incognito then "chrome://newtab" else chrome.runtime.getURL newTabUrl]            else              [newTabUrl] -    urls = request.urls[..].reverse() -    do openNextUrl = (request) -> -      if 0 < urls.length -        TabOperations.openUrlInNewTab (extend request, {url: urls.pop()}), (tab) -> -          openNextUrl extend request, {tab, tabId: tab.id} -      else -        callback request +    if request.registryEntry.options.incognito or request.registryEntry.options.window +      windowConfig = +        url: request.urls +        focused: true +        incognito: request.registryEntry.options.incognito ? false +      chrome.windows.create windowConfig, -> callback request +    else +      urls = request.urls[..].reverse() +      do openNextUrl = (request) -> +        if 0 < urls.length +          TabOperations.openUrlInNewTab (extend request, {url: urls.pop()}), (tab) -> +            openNextUrl extend request, {tab, tabId: tab.id} +        else +          callback request    duplicateTab: mkRepeatCommand (request, callback) ->      chrome.tabs.duplicate request.tabId, (tab) -> callback extend request, {tab, tabId: tab.id}    moveTabToNewWindow: ({count, tab}) -> @@ -192,8 +221,6 @@ BackgroundCommands =        startTabIndex = Math.max 0, Math.min activeTabIndex, tabs.length - count        chrome.tabs.remove (tab.id for tab in tabs[startTabIndex...startTabIndex + count])    restoreTab: mkRepeatCommand (request, callback) -> chrome.sessions.restore null, callback request -  openCopiedUrlInCurrentTab: (request) -> TabOperations.openUrlInCurrentTab extend request, url: Clipboard.paste() -  openCopiedUrlInNewTab: (request) -> @createTab extend request, url: Clipboard.paste()    togglePinTab: ({tab}) -> chrome.tabs.update tab.id, {pinned: !tab.pinned}    toggleMuteTab: toggleMuteTab    moveTabLeft: moveTab @@ -226,7 +253,7 @@ removeTabsRelative = (direction, {tab: activeTab}) ->  # Selects a tab before or after the currently selected tab.  # - direction: "next", "previous", "first" or "last".  selectTab = (direction, {count, tab}) -> -  chrome.tabs.getAllInWindow null, (tabs) -> +  chrome.tabs.query { currentWindow: true }, (tabs) ->      if 1 < tabs.length        toSelect =          switch direction @@ -238,12 +265,11 @@ selectTab = (direction, {count, tab}) ->              Math.min tabs.length - 1, count - 1            when "last"              Math.max 0, tabs.length - count -      chrome.tabs.update tabs[toSelect].id, selected: true +      chrome.tabs.update tabs[toSelect].id, active: true -chrome.tabs.onUpdated.addListener (tabId, changeInfo, tab) -> -  return unless changeInfo.status == "loading" # Only do this once per URL change. +chrome.webNavigation.onCommitted.addListener ({tabId, frameId}) ->    cssConf = -    allFrames: true +    frameId: frameId      code: Settings.get("userDefinedLinkHintCss")      runAt: "document_start"    chrome.tabs.insertCSS tabId, cssConf, -> chrome.runtime.lastError @@ -275,8 +301,9 @@ for icon in [ENABLED_ICON, DISABLED_ICON, PARTIAL_ICON]  Frames =    onConnect: (sender, port) ->      [tabId, frameId] = [sender.tab.id, sender.frameId] -    port.onDisconnect.addListener -> Frames.unregisterFrame {tabId, frameId} +    port.onDisconnect.addListener -> Frames.unregisterFrame {tabId, frameId, port}      port.postMessage handler: "registerFrameId", chromeFrameId: frameId +    (portsForTab[tabId] ?= {})[frameId] = port      # Return our onMessage handler for this port.      (request, port) => @@ -284,13 +311,12 @@ Frames =    registerFrame: ({tabId, frameId, port}) ->      frameIdsForTab[tabId].push frameId unless frameId in frameIdsForTab[tabId] ?= [] -    (portsForTab[tabId] ?= {})[frameId] = port -  unregisterFrame: ({tabId, frameId}) -> -    # FrameId 0 is the top/main frame.  We never unregister that frame.  If the tab is closing, then we tidy -    # up elsewhere.  If the tab is navigating to a new page, then a new top frame will be along soon. -    # This mitigates against the unregister and register messages arriving out of order. See #2125. -    if 0 < frameId +  unregisterFrame: ({tabId, frameId, port}) -> +    # Check that the port trying to unregister the frame hasn't already been replaced by a new frame +    # registering. See #2125. +    registeredPort = portsForTab[tabId]?[frameId] +    if registeredPort == port or not registeredPort        if tabId of frameIdsForTab          frameIdsForTab[tabId] = (fId for fId in frameIdsForTab[tabId] when fId != frameId)        if tabId of portsForTab @@ -299,10 +325,11 @@ Frames =    isEnabledForUrl: ({request, tabId, port}) ->      urlForTab[tabId] = request.url if request.frameIsFocused +    request.isFirefox = Utils.isFirefox() # Update the value for Utils.isFirefox in the frontend.      enabledState = Exclusions.isEnabledForUrl request.url      if request.frameIsFocused -      chrome.browserAction.setIcon tabId: tabId, imageData: do -> +      chrome.browserAction.setIcon? tabId: tabId, imageData: do ->          enabledStateIcon =            if not enabledState.isEnabledForUrl              DISABLED_ICON @@ -333,7 +360,7 @@ handleFrameFocused = ({tabId, frameId}) ->  # Rotate through frames to the frame count places after frameId.  cycleToFrame = (frames, frameId, count = 0) -> -  # We can't always track which frame chrome has focussed, but here we learn that it's frameId; so add an +  # We can't always track which frame chrome has focused, but here we learn that it's frameId; so add an    # additional offset such that we do indeed start from frameId.    count = (count + Math.max 0, frames.indexOf frameId) % frames.length    [frames[count..]..., frames[0...count]...] @@ -362,7 +389,8 @@ HintCoordinator =    prepareToActivateMode: (tabId, originatingFrameId, {modeIndex, isVimiumHelpDialog}) ->      @tabState[tabId] = {frameIds: frameIdsForTab[tabId][..], hintDescriptors: {}, originatingFrameId, modeIndex} -    @tabState[tabId].ports = extend {}, portsForTab[tabId] +    @tabState[tabId].ports = {} +    frameIdsForTab[tabId].map (frameId) => @tabState[tabId].ports[frameId] = portsForTab[tabId][frameId]      @sendMessage "getHintDescriptors", tabId, {modeIndex, isVimiumHelpDialog}    # Receive hint descriptors from all frames and activate link-hints mode when we have them all. @@ -401,15 +429,14 @@ sendRequestHandlers =    # getCurrentTabUrl is used by the content scripts to get their full URL, because window.location cannot help    # with Chrome-specific URLs like "view-source:http:..".    getCurrentTabUrl: ({tab}) -> tab.url -  openUrlInNewTab: (request) -> TabOperations.openUrlInNewTab request +  openUrlInNewTab: mkRepeatCommand (request, callback) -> TabOperations.openUrlInNewTab request, callback +  openUrlInNewWindow: (request) -> TabOperations.openUrlInNewWindow request    openUrlInIncognito: (request) -> chrome.windows.create incognito: true, url: Utils.convertToUrl request.url    openUrlInCurrentTab: TabOperations.openUrlInCurrentTab    openOptionsPageInNewTab: (request) ->      chrome.tabs.create url: chrome.runtime.getURL("pages/options.html"), index: request.tab.index + 1    frameFocused: handleFrameFocused    nextFrame: BackgroundCommands.nextFrame -  copyToClipboard: Clipboard.copy.bind Clipboard -  pasteFromClipboard: Clipboard.paste.bind Clipboard    selectSpecificTab: selectSpecificTab    createMark: Marks.create.bind(Marks)    gotoMark: Marks.goto.bind(Marks) @@ -426,7 +453,7 @@ chrome.tabs.onRemoved.addListener (tabId) ->    delete cache[tabId] for cache in [frameIdsForTab, urlForTab, portsForTab, HintCoordinator.tabState]    chrome.storage.local.get "findModeRawQueryListIncognito", (items) ->      if items.findModeRawQueryListIncognito -      chrome.windows.getAll null, (windows) -> +      chrome.windows?.getAll null, (windows) ->          for window in windows            return if window.incognito          # There are no remaining incognito-mode tabs, and findModeRawQueryListIncognito is set. @@ -467,7 +494,7 @@ do showUpgradeMessage = ->              Settings.set "previousVersion", currentVersion              chrome.notifications.onClicked.addListener (id) ->                if id == notificationId -                chrome.tabs.getSelected null, (tab) -> +                chrome.tabs.query { active: true, currentWindow: true }, ([tab]) ->                    TabOperations.openUrlInNewTab {tab, tabId: tab.id, url: "https://github.com/philc/vimium#release-notes"}        else          # We need to wait for the user to accept the "notifications" permission. diff --git a/background_scripts/marks.coffee b/background_scripts/marks.coffee index dbc14671..77b07b41 100644 --- a/background_scripts/marks.coffee +++ b/background_scripts/marks.coffee @@ -30,7 +30,7 @@ Marks =    saveMark: (markInfo) ->      item = {}      item[@getLocationKey markInfo.markName] = markInfo -    chrome.storage.sync.set item +    Settings.storage.set item    # Goto a global mark.  We try to find the original tab.  If we can't find that, then we try to find another    # tab with the original URL, and use that.  And if we can't find such an existing tab, then we create a new @@ -39,7 +39,7 @@ Marks =      chrome.storage.local.get "vimiumSecret", (items) =>        vimiumSecret = items.vimiumSecret        key = @getLocationKey req.markName -      chrome.storage.sync.get key, (items) => +      Settings.storage.get key, (items) =>          markInfo = items[key]          if markInfo.vimiumSecret != vimiumSecret            # This is a different Vimium instantiation, so markInfo.tabId is definitely out of date. @@ -57,7 +57,7 @@ Marks =    # Focus an existing tab and scroll to the given position within it.    gotoPositionInTab: ({ tabId, scrollX, scrollY, markName }) -> -    chrome.tabs.update tabId, {selected: true}, -> +    chrome.tabs.update tabId, { active: true }, ->        chrome.tabs.sendMessage tabId, {name: "setScrollPosition", scrollX, scrollY}    # The tab we're trying to find no longer exists.  We either find another tab with a matching URL and use it, @@ -82,7 +82,7 @@ Marks =    # Given a list of tabs candidate tabs, pick one.  Prefer tabs in the current window and tabs with shorter    # (matching) URLs.    pickTab: (tabs, callback) -> -    chrome.windows.getCurrent ({ id }) -> +    tabPicker = ({ id }) ->        # Prefer tabs in the current window, if there are any.        tabsInWindow = tabs.filter (tab) -> tab.windowId == id        tabs = tabsInWindow if 0 < tabsInWindow.length @@ -92,6 +92,10 @@ Marks =        # Prefer shorter URLs.        tabs.sort (a,b) -> a.url.length - b.url.length        callback tabs[0] +    if chrome.windows? +      chrome.windows.getCurrent tabPicker +    else +      tabPicker({id: undefined})  root = exports ? window  root.Marks = Marks diff --git a/content_scripts/hud.coffee b/content_scripts/hud.coffee index b2780491..42a960da 100644 --- a/content_scripts/hud.coffee +++ b/content_scripts/hud.coffee @@ -9,6 +9,8 @@ HUD =    findMode: null    abandon: -> @hudUI?.hide false +  pasteListener: null # Set by @pasteFromClipboard to handle the value returned by pasteResponse +    # This HUD is styled to precisely mimick the chrome HUD on Mac. Use the "has_popup_and_link_hud.html"    # test harness to tweak these styles to match Chrome's. One limitation of our HUD display is that    # it doesn't sit on top of horizontal scrollbars like Chrome's HUD does. @@ -35,7 +37,9 @@ HUD =        @tween.fade 1.0, 150    search: (data) -> -    @findMode.findInPlace data.query +    # NOTE(mrmr1993): On Firefox, window.find moves the window focus away from the HUD. We use postFindFocus +    # to put it back, so the user can continue typing. +    @findMode.findInPlace data.query, {"postFindFocus": @hudUI.iframeElement.contentWindow}      # Show the number of matches in the HUD UI.      matchCount = if FindMode.query.parsedQuery.length > 0 then FindMode.query.matchCount else 0 @@ -66,20 +70,47 @@ HUD =      focusNode = DomUtils.getSelectionFocusElement()      document.activeElement?.blur() -    focusNode?.focus() +    focusNode?.focus?()      if exitEventIsEnter -      handleEnterForFindMode() +      FindMode.handleEnter()        if FindMode.query.hasResults          postExit = -> new PostFindMode      else if exitEventIsEscape -      # We don't want FindMode to handle the click events that handleEscapeForFindMode can generate, so we +      # We don't want FindMode to handle the click events that FindMode.handleEscape can generate, so we        # wait until the mode is closed before running it. -      postExit = handleEscapeForFindMode +      postExit = FindMode.handleEscape      @findMode.exit()      postExit?() +  # These commands manage copying and pasting from the clipboard in the HUD frame. +  # NOTE(mrmr1993): We need this to copy and paste on Firefox: +  # * an element can't be focused in the background page, so copying/pasting doesn't work +  # * we don't want to disrupt the focus in the page, in case the page is listening for focus/blur events. +  # * the HUD shouldn't be active for this frame while any of the copy/paste commands are running. +  copyToClipboard: (text) -> +    DomUtils.documentComplete => +      @init() +      @hudUI?.postMessage {name: "copyToClipboard", data: text} + +  pasteFromClipboard: (@pasteListener) -> +    DomUtils.documentComplete => +      @init() +      # Show the HUD frame, so Firefox will actually perform the paste. +      @hudUI.toggleIframeElementClasses "vimiumUIComponentHidden", "vimiumUIComponentVisible" +      @tween.fade 0, 0 +      @hudUI.postMessage {name: "pasteFromClipboard"} + +  pasteResponse: ({data}) -> +    # Hide the HUD frame again. +    @hudUI.toggleIframeElementClasses "vimiumUIComponentVisible", "vimiumUIComponentHidden" +    @unfocusIfFocused() +    @pasteListener data + +  unfocusIfFocused: -> +    document.activeElement.blur() if document.activeElement == @hudUI?.iframeElement +  class Tween    opacity: 0    intervalId: -1 @@ -125,5 +156,6 @@ class Tween        }      """ -root = exports ? window +root = exports ? (window.root ?= {})  root.HUD = HUD +extend window, root unless exports? diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee index 0014e20a..0592c96d 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -31,7 +31,7 @@ COPY_LINK_URL =    indicator: "Copy link URL to Clipboard"    linkActivator: (link) ->      if link.href? -      chrome.runtime.sendMessage handler: "copyToClipboard", data: link.href +      HUD.copyToClipboard link.href        url = link.href        url = url[0..25] + "...." if 28 < url.length        HUD.showForDuration "Yanked #{url}", 2000 @@ -128,7 +128,7 @@ LinkHints =          if isSuccess            # Wait for the next tick to allow the previous mode to exit.  It might yet generate a click event,            # which would cause our new mode to exit immediately. -          Utils.nextTick -> LinkHints.activateMode count-1, mode +          Utils.nextTick -> LinkHints.activateMode count-1, {mode}    activateModeToOpenInNewTab: (count) -> @activateMode count, mode: OPEN_IN_NEW_BG_TAB    activateModeToOpenInNewForegroundTab: (count) -> @activateMode count, mode: OPEN_IN_NEW_FG_TAB @@ -166,17 +166,15 @@ class LinkHintsMode        name: "hint/#{@mode.name}"        indicator: false        singleton: "link-hints-mode" -      passInitialKeyupEvents: true        suppressAllKeyboardEvents: true        suppressTrailingKeyEvents: true        exitOnEscape: true        exitOnClick: true        keydown: @onKeyDownInMode.bind this -      keypress: @onKeyPressInMode.bind this      @hintMode.onExit (event) =>        if event?.type == "click" or (event?.type == "keydown" and -        (KeyboardUtils.isEscape(event) or event.keyCode in [keyCodes.backspace, keyCodes.deleteKey])) +        (KeyboardUtils.isEscape(event) or KeyboardUtils.isBackspace event))            HintCoordinator.sendMessage "exit", isSuccess: false      # Note(philc): Append these markers as top level children instead of as child nodes to the link itself, @@ -230,86 +228,72 @@ class LinkHintsMode        linkText: desc.linkText        stableSortCount: ++@stableSortCount -  # Handles <Shift> and <Ctrl>. +  # Handles all keyboard events.    onKeyDownInMode: (event) ->      return if event.repeat -    @keydownKeyChar = KeyboardUtils.getKeyChar(event).toLowerCase() -    previousTabCount = @tabCount -    @tabCount = 0 - -    # NOTE(smblott) As of 1.54, the Ctrl modifier doesn't work for filtered link hints; therefore we only -    # offer the control modifier for alphabet hints.  It is not clear whether we should fix this.  As of -    # 16-03-28, nobody has complained. -    modifiers = [keyCodes.shiftKey] -    modifiers.push keyCodes.ctrlKey unless Settings.get "filterLinkHints" - -    if event.keyCode in modifiers and +    # NOTE(smblott) The modifier behaviour here applies only to alphabet hints. +    if event.key in ["Control", "Shift"] and not Settings.get("filterLinkHints") and        @mode in [ OPEN_IN_CURRENT_TAB, OPEN_WITH_QUEUE, OPEN_IN_NEW_BG_TAB, OPEN_IN_NEW_FG_TAB ] -        @tabCount = previousTabCount          # Toggle whether to open the link in a new or current tab.          previousMode = @mode -        keyCode = event.keyCode +        key = event.key -        switch keyCode -          when keyCodes.shiftKey +        switch key +          when "Shift"              @setOpenLinkMode(if @mode is OPEN_IN_CURRENT_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_CURRENT_TAB) -          when keyCodes.ctrlKey +          when "Control"              @setOpenLinkMode(if @mode is OPEN_IN_NEW_FG_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_NEW_FG_TAB) -        handlerId = handlerStack.push +        handlerId = @hintMode.push            keyup: (event) => -            if event.keyCode == keyCode +            if event.key == key                handlerStack.remove()                @setOpenLinkMode previousMode              true # Continue bubbling the event. -        # For some (unknown) reason, we don't always receive the keyup event needed to remove this handler. -        # Therefore, we ensure that it's always removed when hint mode exits.  See #1911 and #1926. -        @hintMode.onExit -> handlerStack.remove handlerId - -    else if event.keyCode in [ keyCodes.backspace, keyCodes.deleteKey ] +    else if KeyboardUtils.isBackspace event        if @markerMatcher.popKeyChar() +        @tabCount = 0          @updateVisibleMarkers()        else          # Exit via @hintMode.exit(), so that the LinkHints.activate() "onExit" callback sees the key event and          # knows not to restart hints mode.          @hintMode.exit event -    else if event.keyCode == keyCodes.enter +    else if event.key == "Enter"        # Activate the active hint, if there is one.  Only FilterHints uses an active hint.        HintCoordinator.sendMessage "activateActiveHintMarker" if @markerMatcher.activeHintMarker -    else if event.keyCode == keyCodes.tab -      @tabCount = previousTabCount + (if event.shiftKey then -1 else 1) -      @updateVisibleMarkers @tabCount +    else if event.key == "Tab" +      if event.shiftKey then @tabCount-- else @tabCount++ +      @updateVisibleMarkers() -    else if event.keyCode == keyCodes.space and @markerMatcher.shouldRotateHints event -      @tabCount = previousTabCount +    else if event.key == " " and @markerMatcher.shouldRotateHints event        HintCoordinator.sendMessage "rotateHints"      else -      @tabCount = previousTabCount if event.ctrlKey or event.metaKey or event.altKey -      return - -    # We've handled the event, so suppress it and update the mode indicator. -    DomUtils.suppressEvent event - -  # Handles normal input. -  onKeyPressInMode: (event) -> -    return if event.repeat - -    keyChar = String.fromCharCode(event.charCode).toLowerCase() -    if keyChar -      @markerMatcher.pushKeyChar keyChar, @keydownKeyChar -      @updateVisibleMarkers() +      unless event.repeat +        keyChar = +          if Settings.get "filterLinkHints" +            KeyboardUtils.getKeyChar(event) +          else +            KeyboardUtils.getKeyChar(event).toLowerCase() +        if keyChar +          keyChar = " " if keyChar == "space" +          if keyChar.length == 1 +            @tabCount = 0 +            @markerMatcher.pushKeyChar keyChar +            @updateVisibleMarkers() +          else +            return handlerStack.suppressPropagation -    # We've handled the event, so suppress it. -    DomUtils.suppressEvent event +    handlerStack.suppressEvent -  updateVisibleMarkers: (tabCount = 0) -> +  updateVisibleMarkers: ->      {hintKeystrokeQueue, linkTextKeystrokeQueue} = @markerMatcher -    HintCoordinator.sendMessage "updateKeyState", {hintKeystrokeQueue, linkTextKeystrokeQueue, tabCount} +    HintCoordinator.sendMessage "updateKeyState", +      {hintKeystrokeQueue, linkTextKeystrokeQueue, tabCount: @tabCount}    updateKeyState: ({hintKeystrokeQueue, linkTextKeystrokeQueue, tabCount}) ->      extend @markerMatcher, {hintKeystrokeQueue, linkTextKeystrokeQueue} @@ -318,7 +302,7 @@ class LinkHintsMode      if linksMatched.length == 0        @deactivateMode()      else if linksMatched.length == 1 -      @activateLink linksMatched[0], userMightOverType ? false +      @activateLink linksMatched[0], userMightOverType      else        @hideMarker marker for marker in @hintMarkers        @showMarker matched, @markerMatcher.hintKeystrokeQueue.length for matched in linksMatched @@ -329,7 +313,7 @@ class LinkHintsMode    rotateHints: do ->      markerOverlapsStack = (marker, stack) ->        for otherMarker in stack -        return true if Rect.rectsOverlap marker.markerRect, otherMarker.markerRect +        return true if Rect.intersects marker.markerRect, otherMarker.markerRect        false      -> @@ -372,7 +356,7 @@ class LinkHintsMode    # When only one hint remains, activate it in the appropriate way.  The current frame may or may not contain    # the matched link, and may or may not have the focus.  The resulting four cases are accounted for here by    # selectively pushing the appropriate HintCoordinator.onExit handlers. -  activateLink: (linkMatched, userMightOverType=false) -> +  activateLink: (linkMatched, userMightOverType = false) ->      @removeHintMarkers()      if linkMatched.isLocalMarker @@ -398,25 +382,26 @@ class LinkHintsMode                clickEl.focus()              linkActivator clickEl -    installKeyboardBlocker = (startKeyboardBlocker) -> -      if linkMatched.isLocalMarker -        {top: viewportTop, left: viewportLeft} = DomUtils.getViewportTopLeft() -        for rect in (Rect.copy rect for rect in clickEl.getClientRects()) -          extend rect, top: rect.top + viewportTop, left: rect.left + viewportLeft -          flashEl = DomUtils.addFlashRect rect -          do (flashEl) -> HintCoordinator.onExit.push -> DomUtils.removeElement flashEl - -      if windowIsFocused() -        startKeyboardBlocker (isSuccess) -> HintCoordinator.sendMessage "exit", {isSuccess} +    # If flash elements are created, then this function can be used later to remove them. +    removeFlashElements = -> +    if linkMatched.isLocalMarker +      {top: viewportTop, left: viewportLeft} = DomUtils.getViewportTopLeft() +      flashElements = for rect in clickEl.getClientRects() +        DomUtils.addFlashRect Rect.translate rect, viewportLeft, viewportTop +      removeFlashElements = -> DomUtils.removeElement flashEl for flashEl in flashElements      # If we're using a keyboard blocker, then the frame with the focus sends the "exit" message, otherwise the      # frame containing the matched link does. -    if userMightOverType and Settings.get "waitForEnterForFilteredHints" -      installKeyboardBlocker (callback) -> new WaitForEnter callback -    else if userMightOverType -      installKeyboardBlocker (callback) -> new TypingProtector 200, callback +    if userMightOverType +      HintCoordinator.onExit.push removeFlashElements +      if windowIsFocused() +        callback = (isSuccess) -> HintCoordinator.sendMessage "exit", {isSuccess} +        if Settings.get "waitForEnterForFilteredHints" +          new WaitForEnter callback +        else +          new TypingProtector 200, callback      else if linkMatched.isLocalMarker -      DomUtils.flashRect linkMatched.rect +      Utils.setTimeout 400, removeFlashElements        HintCoordinator.sendMessage "exit", isSuccess: true    # @@ -444,12 +429,7 @@ class LinkHintsMode  # Use characters for hints, and do not filter links by their text.  class AlphabetHints    constructor: -> -    @linkHintCharacters = Settings.get "linkHintCharacters" -    # We use the keyChar from keydown if the link-hint characters are all "a-z0-9".  This is the default -    # settings value, and preserves the legacy behavior (which always used keydown) for users which are -    # familiar with that behavior.  Otherwise, we use keyChar from keypress, which admits non-Latin -    # characters. See #1722. -    @useKeydown = /^[a-z0-9]*$/.test @linkHintCharacters +    @linkHintCharacters = Settings.get("linkHintCharacters").toLowerCase()      @hintKeystrokeQueue = []    fillInMarkers: (hintMarkers) -> @@ -478,17 +458,17 @@ class AlphabetHints      matchString = @hintKeystrokeQueue.join ""      linksMatched: hintMarkers.filter (linkMarker) -> linkMarker.hintString.startsWith matchString -  pushKeyChar: (keyChar, keydownKeyChar) -> -    @hintKeystrokeQueue.push (if @useKeydown then keydownKeyChar else keyChar) +  pushKeyChar: (keyChar) -> +    @hintKeystrokeQueue.push keyChar    popKeyChar: -> @hintKeystrokeQueue.pop()    # For alphabet hints, <Space> always rotates the hints, regardless of modifiers.    shouldRotateHints: -> true -# Use numbers (usually) for hints, and also filter links by their text. +# Use characters for hints, and also filter links by their text.  class FilterHints    constructor: -> -    @linkHintNumbers = Settings.get "linkHintNumbers" +    @linkHintNumbers = Settings.get("linkHintNumbers").toUpperCase()      @hintKeystrokeQueue = []      @linkTextKeystrokeQueue = []      @activeHintMarker = null @@ -535,17 +515,18 @@ class FilterHints      linksMatched: linksMatched      userMightOverType: @hintKeystrokeQueue.length == 0 and 0 < @linkTextKeystrokeQueue.length -  pushKeyChar: (keyChar, keydownKeyChar) -> -    # For filtered hints, we *always* use the keyChar value from keypress, because there is no obvious and -    # easy-to-understand meaning for choosing one of keyChar or keydownKeyChar (as there is for alphabet -    # hints). +  pushKeyChar: (keyChar) ->      if 0 <= @linkHintNumbers.indexOf keyChar        @hintKeystrokeQueue.push keyChar +    else if keyChar.toLowerCase() != keyChar and @linkHintNumbers.toLowerCase() != @linkHintNumbers.toUpperCase() +      # The the keyChar is upper case and the link hint "numbers" contain characters (e.g. [a-zA-Z]).  We don't want +      # some upper-case letters matching hints (above) and some matching text (below), so we ignore such keys. +      return      # We only accept <Space> and characters which are not used for splitting (e.g. "a", "b", etc., but not "-").      else if keyChar == " " or not @splitRegexp.test keyChar        # Since we might renumber the hints, we should reset the current hintKeyStrokeQueue.        @hintKeystrokeQueue = [] -      @linkTextKeystrokeQueue.push keyChar +      @linkTextKeystrokeQueue.push keyChar.toLowerCase()    popKeyChar: ->      @hintKeystrokeQueue.pop() or @linkTextKeystrokeQueue.pop() @@ -626,7 +607,9 @@ LocalHints =    # image), therefore we always return a array of element/rect pairs (which may also be a singleton or empty).    #    getVisibleClickable: (element) -> -    tagName = element.tagName.toLowerCase() +    # Get the tag name.  However, `element.tagName` can be an element (not a string, see #2305), so we guard +    # against that. +    tagName = element.tagName.toLowerCase?() ? ""      isClickable = false      onlyHasTabIndex = false      possibleFalsePositive = false @@ -668,9 +651,12 @@ LocalHints =      isClickable ||= @checkForAngularJs element      # Check for attributes that make an element clickable regardless of its tagName. -    if (element.hasAttribute("onclick") or -        element.getAttribute("role")?.toLowerCase() in ["button", "link"] or -        element.getAttribute("contentEditable")?.toLowerCase() in ["", "contentEditable", "true"]) +    if element.hasAttribute("onclick") or +        (role = element.getAttribute "role") and role.toLowerCase() in [ +          "button" , "tab" , "link", "checkbox", "menuitem", "menuitemcheckbox", "menuitemradio" +        ] or +        (contentEditable = element.getAttribute "contentEditable") and +          contentEditable.toLowerCase() in ["", "contenteditable", "true"]        isClickable = true      # Check for jsaction event listeners on the element. @@ -821,25 +807,10 @@ LocalHints =        hint.rect.left += left      if Settings.get "filterLinkHints" -      @withLabelMap (labelMap) => -        extend hint, @generateLinkText labelMap, hint for hint in localHints +      extend hint, @generateLinkText hint for hint in localHints      localHints -  # Generate a map of input element => label text, call a callback with it. -  withLabelMap: (callback) -> -    labelMap = {} -    labels = document.querySelectorAll "label" -    for label in labels -      forElement = label.getAttribute "for" -      if forElement -        labelText = label.textContent.trim() -        # Remove trailing ":" commonly found in labels. -        if labelText[labelText.length-1] == ":" -          labelText = labelText.substr 0, labelText.length-1 -        labelMap[forElement] = labelText -    callback labelMap - -  generateLinkText: (labelMap, hint) -> +  generateLinkText: (hint) ->      element = hint.element      linkText = ""      showLinkText = false @@ -847,9 +818,14 @@ LocalHints =      nodeName = element.nodeName.toLowerCase()      if nodeName == "input" -      if labelMap[element.id] -        linkText = labelMap[element.id] +      if element.labels? and element.labels.length > 0 +        linkText = element.labels[0].textContent.trim() +        # Remove trailing ":" commonly found in labels. +        if linkText[linkText.length-1] == ":" +          linkText = linkText[...linkText.length-1]          showLinkText = true +      else if element.getAttribute("type")?.toLowerCase() == "file" +        linkText = "Choose File"        else if element.type != "password"          linkText = element.value          if not linkText and 'placeholder' of element @@ -899,15 +875,16 @@ class WaitForEnter extends Mode      @push        keydown: (event) => -        if event.keyCode == keyCodes.enter +        if event.key == "Enter"            @exit()            callback true # true -> isSuccess.          else if KeyboardUtils.isEscape event            @exit()            callback false # false -> isSuccess. -root = exports ? window +root = exports ? (window.root ?= {})  root.LinkHints = LinkHints  root.HintCoordinator = HintCoordinator  # For tests:  extend root, {LinkHintsMode, LocalHints, AlphabetHints, WaitForEnter} +extend window, root unless exports? diff --git a/content_scripts/marks.coffee b/content_scripts/marks.coffee index 73191b1b..fb1d1b1d 100644 --- a/content_scripts/marks.coffee +++ b/content_scripts/marks.coffee @@ -40,22 +40,24 @@ Marks =        indicator: "Create mark..."        exitOnEscape: true        suppressAllKeyboardEvents: true -      keypress: (event) => -        keyChar = String.fromCharCode event.charCode -        @exit => -          if @isGlobalMark event, keyChar -            # We record the current scroll position, but only if this is the top frame within the tab. -            # Otherwise, we'll fetch the scroll position of the top frame from the background page later. -            [ scrollX, scrollY ] = [ window.scrollX, window.scrollY ] if DomUtils.isTopFrame() -            chrome.runtime.sendMessage -              handler: 'createMark' -              markName: keyChar -              scrollX: scrollX -              scrollY: scrollY -            , => @showMessage "Created global mark", keyChar -          else -            localStorage[@getLocationKey keyChar] = @getMarkString() -            @showMessage "Created local mark", keyChar +      keydown: (event) => +        if KeyboardUtils.isPrintable event +          keyChar = KeyboardUtils.getKeyChar event +          @exit => +            if @isGlobalMark event, keyChar +              # We record the current scroll position, but only if this is the top frame within the tab. +              # Otherwise, we'll fetch the scroll position of the top frame from the background page later. +              [ scrollX, scrollY ] = [ window.scrollX, window.scrollY ] if DomUtils.isTopFrame() +              chrome.runtime.sendMessage +                handler: 'createMark' +                markName: keyChar +                scrollX: scrollX +                scrollY: scrollY +              , => @showMessage "Created global mark", keyChar +            else +              localStorage[@getLocationKey keyChar] = @getMarkString() +              @showMessage "Created local mark", keyChar +          handlerStack.suppressEvent    activateGotoMode: (count, {registryEntry}) ->      @currentRegistryEntry = registryEntry @@ -64,27 +66,30 @@ Marks =        indicator: "Go to mark..."        exitOnEscape: true        suppressAllKeyboardEvents: true -      keypress: (event) => -        @exit => -          markName = String.fromCharCode event.charCode -          if @isGlobalMark event, markName -            # This key must match @getLocationKey() in the back end. -            key = "vimiumGlobalMark|#{markName}" -            chrome.storage.sync.get key, (items) -> -              if key of items -                chrome.runtime.sendMessage handler: 'gotoMark', markName: markName -                HUD.showForDuration "Jumped to global mark '#{markName}'", 1000 -              else -                HUD.showForDuration "Global mark not set '#{markName}'", 1000 -          else -            markString = @localRegisters[markName] ? localStorage[@getLocationKey markName] -            if markString? -              @setPreviousPosition() -              position = JSON.parse markString -              window.scrollTo position.scrollX, position.scrollY -              @showMessage "Jumped to local mark", markName +      keydown: (event) => +        if KeyboardUtils.isPrintable event +          @exit => +            keyChar = KeyboardUtils.getKeyChar event +            if @isGlobalMark event, keyChar +              # This key must match @getLocationKey() in the back end. +              key = "vimiumGlobalMark|#{keyChar}" +              Settings.storage.get key, (items) -> +                if key of items +                  chrome.runtime.sendMessage handler: 'gotoMark', markName: keyChar +                  HUD.showForDuration "Jumped to global mark '#{keyChar}'", 1000 +                else +                  HUD.showForDuration "Global mark not set '#{keyChar}'", 1000              else -              @showMessage "Local mark not set", markName +              markString = @localRegisters[keyChar] ? localStorage[@getLocationKey keyChar] +              if markString? +                @setPreviousPosition() +                position = JSON.parse markString +                window.scrollTo position.scrollX, position.scrollY +                @showMessage "Jumped to local mark", keyChar +              else +                @showMessage "Local mark not set", keyChar +          handlerStack.suppressEvent -root = exports ? window +root = exports ? (window.root ?= {})  root.Marks =  Marks +extend window, root unless exports? diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee index 6508627e..a4a91c1f 100644 --- a/content_scripts/mode.coffee +++ b/content_scripts/mode.coffee @@ -55,7 +55,7 @@ class Mode      # the need for modes which suppress all keyboard events 1) to provide handlers for all of those events,      # or 2) to worry about event suppression and event-handler return values.      if @options.suppressAllKeyboardEvents -      for type in [ "keydown", "keypress", "keyup" ] +      for type in [ "keydown", "keypress" ]          do (handler = @options[type]) =>            @options[type] = (event) => @alwaysSuppressPropagation => handler? event @@ -81,8 +81,7 @@ class Mode          _name: "mode-#{@id}/exitOnEscape"          "keydown": (event) =>            return @continueBubbling unless KeyboardUtils.isEscape event -          DomUtils.suppressKeyupAfterEscape handlerStack -          @exit event, event.srcElement +          @exit event, event.target            @suppressEvent      # If @options.exitOnBlur is truthy, then it should be an element.  The mode will exit when that element @@ -121,16 +120,6 @@ class Mode        singletons[key]?.exit()        singletons[key] = this -    # If @options.passInitialKeyupEvents is set, then we pass initial non-printable keyup events to the page -    # or to other extensions (because the corresponding keydown events were passed).  This is used when -    # activating link hints, see #1522. -    if @options.passInitialKeyupEvents -      @push -        _name: "mode-#{@id}/passInitialKeyupEvents" -        keydown: => @alwaysContinueBubbling -> handlerStack.remove() -        keyup: (event) => -          if KeyboardUtils.isPrintable event then @suppressPropagation else @passEventToPage -      # if @options.suppressTrailingKeyEvents is set, then  -- on exit -- we suppress all key events until a      # subsquent (non-repeat) keydown or keypress.  In particular, the intention is to catch keyup events for      # keys which we have handled, but which otherwise might trigger page actions (if the page is listening for @@ -148,7 +137,6 @@ class Mode            name: "suppress-trailing-key-events"            keydown: handler            keypress: handler -          keyup: -> handlerStack.suppressPropagation      Mode.modes.push this      @setIndicator() @@ -174,15 +162,16 @@ class Mode      @exitHandlers.push handler    exit: (args...) -> -    if @modeIsActive -      @log "deactivate:", @id -      unless @modeIsExiting -        @modeIsExiting = true -        handler args... for handler in @exitHandlers -        handlerStack.remove handlerId for handlerId in @handlers -      Mode.modes = Mode.modes.filter (mode) => mode != this -      @modeIsActive = false -      @setIndicator() +    return if @modeIsExiting or not @modeIsActive +    @log "deactivate:", @id +    @modeIsExiting = true + +    handler args... for handler in @exitHandlers +    handlerStack.remove handlerId for handlerId in @handlers +    Mode.modes = Mode.modes.filter (mode) => mode != this + +    @modeIsActive = false +    @setIndicator()    # Debugging routines.    logModes: -> @@ -209,5 +198,6 @@ class SuppressAllKeyboardEvents extends Mode        suppressAllKeyboardEvents: true      super extend defaults, options -root = exports ? window +root = exports ? (window.root ?= {})  extend root, {Mode, SuppressAllKeyboardEvents} +extend window, root unless exports? diff --git a/content_scripts/mode_find.coffee b/content_scripts/mode_find.coffee index 8621edf8..f19b5db4 100644 --- a/content_scripts/mode_find.coffee +++ b/content_scripts/mode_find.coffee @@ -6,7 +6,7 @@ class SuppressPrintable extends Mode    constructor: (options) ->      super options      handler = (event) => if KeyboardUtils.isPrintable event then @suppressEvent else @continueBubbling -    type = document.getSelection().type +    type = DomUtils.getSelectionType()      # We use unshift here, so we see events after normal mode, so we only see unmapped keys.      @unshift @@ -16,7 +16,7 @@ class SuppressPrintable extends Mode        keyup: (event) =>          # If the selection type has changed (usually, no longer "Range"), then the user is interacting with          # the input element, so we get out of the way.  See discussion of option 5c from #1415. -        if document.getSelection().type != type then @exit() else handler event +        @exit() if DomUtils.getSelectionType() != type  # When we use find, the selection/focus can land in a focusable/editable element.  In this situation, special  # considerations apply.  We implement three special cases: @@ -47,7 +47,6 @@ class PostFindMode extends SuppressPrintable        _name: "mode-#{@id}/handle-escape"        keydown: (event) =>          if KeyboardUtils.isEscape event -          DomUtils.suppressKeyupAfterEscape handlerStack            @exit()            @suppressEvent          else @@ -80,15 +79,16 @@ class FindMode extends Mode    exit: (event) ->      super() -    handleEscapeForFindMode() if event +    FindMode.handleEscape() if event    restoreSelection: -> +    return unless @initialRange      range = @initialRange      selection = getSelection()      selection.removeAllRanges()      selection.addRange range -  findInPlace: (query) -> +  findInPlace: (query, options) ->      # If requested, restore the scroll position (so that failed searches leave the scroll position unchanged).      @checkReturnToViewPort()      FindMode.updateQuery query @@ -96,7 +96,7 @@ class FindMode extends Mode      # match as the user adds matching characters, or removes previously-matched characters. See #1434.      @restoreSelection()      query = if FindMode.query.isRegex then FindMode.getNextQueryFromRegexMatches(0) else FindMode.query.parsedQuery -    FindMode.query.hasResults = FindMode.execute query +    FindMode.query.hasResults = FindMode.execute query, options    @updateQuery: (query) ->      @query.rawQuery = query @@ -179,7 +179,13 @@ class FindMode extends Mode        # ignore the selectionchange event generated by find()        document.removeEventListener("selectionchange", @restoreDefaultSelectionHighlight, true) -    result = window.find(query, options.caseSensitive, options.backwards, true, false, true, false) +    try +      result = window.find(query, options.caseSensitive, options.backwards, true, false, true, false) +    catch # Failed searches throw on Firefox. + +    # window.find focuses the |window| that it is called on. This gives us an opportunity to (re-)focus +    # another element/window, if that isn't the behaviour we want. +    options.postFindFocus?.focus()      if options.colorSelection        setTimeout( @@ -194,22 +200,73 @@ class FindMode extends Mode      result -  @restoreDefaultSelectionHighlight: -> document.body.classList.remove("vimiumFindMode") +  @restoreDefaultSelectionHighlight: forTrusted -> document.body.classList.remove("vimiumFindMode") + +  # The user has found what they're looking for and is finished searching. We enter insert mode, if possible. +  @handleEscape: -> +    document.body.classList.remove("vimiumFindMode") +    # Removing the class does not re-color existing selections. we recreate the current selection so it reverts +    # back to the default color. +    selection = window.getSelection() +    unless selection.isCollapsed +      range = window.getSelection().getRangeAt(0) +      window.getSelection().removeAllRanges() +      window.getSelection().addRange(range) +    focusFoundLink() || selectFoundInputElement() + +  # Save the query so the user can do further searches with it. +  @handleEnter: -> +    focusFoundLink() +    document.body.classList.add("vimiumFindMode") +    FindMode.saveQuery() + +  @findNext: (backwards) -> +    Marks.setPreviousPosition() +    FindMode.query.hasResults = FindMode.execute null, {backwards} + +    if FindMode.query.hasResults +      focusFoundLink() +      new PostFindMode() +    else +      HUD.showForDuration("No matches for '#{FindMode.query.rawQuery}'", 1000)    checkReturnToViewPort: ->      window.scrollTo @scrollX, @scrollY if @options.returnToViewport  getCurrentRange = ->    selection = getSelection() -  if selection.type == "None" +  if DomUtils.getSelectionType(selection) == "None"      range = document.createRange()      range.setStart document.body, 0      range.setEnd document.body, 0      range    else -    selection.collapseToStart() if selection.type == "Range" +    selection.collapseToStart() if DomUtils.getSelectionType(selection) == "Range"      selection.getRangeAt 0 -root = exports ? window +getLinkFromSelection = -> +  node = window.getSelection().anchorNode +  while (node && node != document.body) +    return node if (node.nodeName.toLowerCase() == "a") +    node = node.parentNode +  null + +focusFoundLink = -> +  if (FindMode.query.hasResults) +    link = getLinkFromSelection() +    link.focus() if link + +selectFoundInputElement = -> +  # Since the last focused element might not be the one currently pointed to by find (e.g.  the current one +  # might be disabled and therefore unable to receive focus), we use the approximate heuristic of checking +  # that the last anchor node is an ancestor of our element. +  findModeAnchorNode = document.getSelection().anchorNode +  if (FindMode.query.hasResults && document.activeElement && +      DomUtils.isSelectable(document.activeElement) && +      DomUtils.isDOMDescendant(findModeAnchorNode, document.activeElement)) +    DomUtils.simulateSelect(document.activeElement) + +root = exports ? (window.root ?= {})  root.PostFindMode = PostFindMode  root.FindMode = FindMode +extend window, root unless exports? diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee index 4cb0a39e..511602e7 100644 --- a/content_scripts/mode_insert.coffee +++ b/content_scripts/mode_insert.coffee @@ -11,93 +11,44 @@ class InsertMode extends Mode      handleKeyEvent = (event) =>        return @continueBubbling unless @isActive event +      # See comment here: https://github.com/philc/vimium/commit/48c169bd5a61685bb4e67b1e76c939dbf360a658. +      activeElement = @getActiveElement() +      return @passEventToPage if activeElement == document.body and activeElement.isContentEditable +        # Check for a pass-next-key key.        if KeyboardUtils.getKeyCharString(event) in Settings.get "passNextKeyKeys"          new PassNextKeyMode -        return @suppressEvent - -      return @passEventToPage unless event.type == 'keydown' and KeyboardUtils.isEscape event -      DomUtils.suppressKeyupAfterEscape handlerStack -      target = event.srcElement -      if target and DomUtils.isFocusable target -        # Remove the focus, so the user can't just get back into insert mode by typing in the same input box. -        target.blur() -      else if target?.shadowRoot and @insertModeLock -        # An editable element in a shadow DOM is focused; blur it. -        @insertModeLock.blur() -      @exit event, event.srcElement -      @suppressEvent + +      else if event.type == 'keydown' and KeyboardUtils.isEscape(event) +        activeElement.blur() if DomUtils.isFocusable activeElement +        @exit() unless @permanent + +      else +        return @passEventToPage + +      return @suppressEvent      defaults =        name: "insert"        indicator: if not @permanent and not Settings.get "hideHud"  then "Insert mode"        keypress: handleKeyEvent -      keyup: handleKeyEvent        keydown: handleKeyEvent      super extend defaults, options -    @insertModeLock = -      if options.targetElement and DomUtils.isEditable options.targetElement -        # The caller has told us which element to activate on. -        options.targetElement -      else if document.activeElement and DomUtils.isEditable document.activeElement -        # An input element is already active, so use it. -        document.activeElement -      else -        null - -    @push -      _name: "mode-#{@id}-focus" -      "blur": (event) => @alwaysContinueBubbling => -        target = event.target -        # We can't rely on focus and blur events arriving in the expected order.  When the active element -        # changes, we might get "focus" before "blur".  We track the active element in @insertModeLock, and -        # exit only when that element blurs. -        @exit event, target if @insertModeLock and target == @insertModeLock -      "focus": (event) => @alwaysContinueBubbling => -        if @insertModeLock != event.target and DomUtils.isFocusable event.target -          @activateOnElement event.target -        else if event.target.shadowRoot -          # A focusable element inside the shadow DOM might have been selected. If so, we can catch the focus -          # event inside the shadow DOM. This fixes #853. -          shadowRoot = event.target.shadowRoot -          eventListeners = {} -          for type in [ "focus", "blur" ] -            eventListeners[type] = do (type) -> -              (event) -> handlerStack.bubbleEvent type, event -            shadowRoot.addEventListener type, eventListeners[type], true - -          handlerStack.push -            _name: "shadow-DOM-input-mode" -            blur: (event) -> -              if event.target.shadowRoot == shadowRoot -                handlerStack.remove() -                for own type, listener of eventListeners -                  shadowRoot.removeEventListener type, listener, true -      # Only for tests.  This gives us a hook to test the status of the permanently-installed instance.      InsertMode.permanentInstance = this if @permanent    isActive: (event) ->      return false if event == InsertMode.suppressedEvent -    return true if @insertModeLock or @global -    # Some sites (e.g. inbox.google.com) change the contentEditable property on the fly (see #1245); and -    # unfortunately, the focus event fires *before* the change.  Therefore, we need to re-check whether the -    # active element is contentEditable. -    @activateOnElement document.activeElement if document.activeElement?.isContentEditable -    @insertModeLock != null - -  activateOnElement: (element) -> -    @log "#{@id}: activating (permanent)" if @debug and @permanent -    @insertModeLock = element - -  exit: (_, target)  -> -    if (target and target == @insertModeLock) or @global or target == undefined -      @log "#{@id}: deactivating (permanent)" if @debug and @permanent and @insertModeLock -      @insertModeLock = null -      # Exit, but only if this isn't the permanently-installed instance. -      super() unless @permanent +    return true if @global +    DomUtils.isFocusable @getActiveElement() + +  getActiveElement: -> +    activeElement = document.activeElement +    while activeElement?.shadowRoot?.activeElement +      activeElement = activeElement.shadowRoot.activeElement +    activeElement    # Static stuff. This allows PostFindMode to suppress the permanently-installed InsertMode instance.    @suppressedEvent: null @@ -129,6 +80,7 @@ class PassNextKeyMode extends Mode                @exit()          @passEventToPage -root = exports ? window +root = exports ? (window.root ?= {})  root.InsertMode = InsertMode  root.PassNextKeyMode = PassNextKeyMode +extend window, root unless exports? diff --git a/content_scripts/mode_key_handler.coffee b/content_scripts/mode_key_handler.coffee index 480a79af..cca6b77a 100644 --- a/content_scripts/mode_key_handler.coffee +++ b/content_scripts/mode_key_handler.coffee @@ -12,7 +12,6 @@  # consists of a (non-empty) list of such mappings.  class KeyHandlerMode extends Mode -  keydownEvents: {}    setKeyMapping: (@keyMapping) -> @reset()    setPassKeys: (@passKeys) -> @reset()    # Only for tests. @@ -28,59 +27,41 @@ class KeyHandlerMode extends Mode      super extend options,        keydown: @onKeydown.bind this -      keypress: @onKeypress.bind this -      keyup: @onKeyup.bind this -      # We cannot track keyup events if we lose the focus. -      blur: (event) => @alwaysContinueBubbling => @keydownEvents = {} if event.target == window -    @mapKeyRegistry = {} -    Utils.monitorChromeStorage "mapKeyRegistry", (value) => @mapKeyRegistry = value +    if options.exitOnEscape +      # If we're part way through a command's key sequence, then a first Escape should reset the key state, +      # and only a second Escape should actually exit this mode. +      @push +        _name: "key-handler-escape-listener" +        keydown: (event) => +          if KeyboardUtils.isEscape(event) and not @isInResetState() +            @reset() +            @suppressEvent +          else +            @continueBubbling    onKeydown: (event) ->      keyChar = KeyboardUtils.getKeyCharString event -    keyChar = @mapKeyRegistry[keyChar] ? keyChar      isEscape = KeyboardUtils.isEscape event      if isEscape and (@countPrefix != 0 or @keyState.length != 1) -      @keydownEvents[event.keyCode] = true -      @reset() -      @suppressEvent +      DomUtils.consumeKeyup event, => @reset()      # If the help dialog loses the focus, then Escape should hide it; see point 2 in #2045.      else if isEscape and HelpDialog?.isShowing() -      @keydownEvents[event.keyCode] = true        HelpDialog.toggle()        @suppressEvent      else if isEscape        @continueBubbling      else if @isMappedKey keyChar -      @keydownEvents[event.keyCode] = true -      @handleKeyChar keyChar -    else if not keyChar and (keyChar = KeyboardUtils.getKeyChar event) and -        (@isMappedKey(keyChar) or @isCountKey keyChar) -      # We will possibly be handling a subsequent keypress event, so suppress propagation of this event to -      # prevent triggering page event listeners (e.g. Google instant Search). -      @keydownEvents[event.keyCode] = true -      @suppressPropagation -    else -      @continueBubbling - -  onKeypress: (event) -> -    keyChar = KeyboardUtils.getKeyCharString event -    keyChar = @mapKeyRegistry[keyChar] ? keyChar -    if @isMappedKey keyChar        @handleKeyChar keyChar +      @suppressEvent      else if @isCountKey keyChar        digit = parseInt keyChar        @reset if @keyState.length == 1 then @countPrefix * 10 + digit else digit        @suppressEvent      else -      @reset() +      @reset() if keyChar        @continueBubbling -  onKeyup: (event) -> -    return @continueBubbling unless event.keyCode of @keydownEvents -    delete @keydownEvents[event.keyCode] -    @suppressPropagation -    # This tests whether there is a mapping of keyChar in the current key state (and accounts for pass keys).    isMappedKey: (keyChar) ->      (mapping for mapping in @keyState when keyChar of mapping)[0]? and not @isPassKey keyChar @@ -92,7 +73,10 @@ class KeyHandlerMode extends Mode    # Keystrokes are *never* considered pass keys if the user has begun entering a command.  So, for example, if    # 't' is a passKey, then the "t"-s of 'gt' and '99t' are neverthless handled as regular keys.    isPassKey: (keyChar) -> -    @countPrefix == 0 and @keyState.length == 1 and keyChar in (@passKeys ? "") +    @isInResetState() and keyChar in (@passKeys ? "") + +  isInResetState: -> +    @countPrefix == 0 and @keyState.length == 1    handleKeyChar: (keyChar) ->      bgLog "handle key #{keyChar} (#{@name})" @@ -106,7 +90,9 @@ class KeyHandlerMode extends Mode        bgLog "  invoke #{command.command} count=#{count} "        @reset()        @commandHandler {command, count} +      @exit() if @options.count? and --@options.count <= 0      @suppressEvent -root = exports ? window +root = exports ? (window.root ?= {})  root.KeyHandlerMode = KeyHandlerMode +extend window, root unless exports? diff --git a/content_scripts/mode_normal.coffee b/content_scripts/mode_normal.coffee new file mode 100644 index 00000000..1fe0618e --- /dev/null +++ b/content_scripts/mode_normal.coffee @@ -0,0 +1,369 @@ +class NormalMode extends KeyHandlerMode +  constructor: (options = {}) -> +    defaults = +      name: "normal" +      indicator: false # There is normally no mode indicator in normal mode. +      commandHandler: @commandHandler.bind this + +    super extend defaults, options + +    chrome.storage.local.get "normalModeKeyStateMapping", (items) => +      @setKeyMapping items.normalModeKeyStateMapping + +    chrome.storage.onChanged.addListener (changes, area) => +      if area == "local" and changes.normalModeKeyStateMapping?.newValue +        @setKeyMapping changes.normalModeKeyStateMapping.newValue + +  commandHandler: ({command: registryEntry, count}) -> +    count *= registryEntry.options.count ? 1 +    count = 1 if registryEntry.noRepeat + +    if registryEntry.repeatLimit? and registryEntry.repeatLimit < count +      return unless confirm """ +        You have asked Vimium to perform #{count} repetitions of the command: #{registryEntry.description}.\n +        Are you sure you want to continue?""" + +    if registryEntry.topFrame +      # We never return to a UI-component frame (e.g. the help dialog), it might have lost the focus. +      sourceFrameId = if window.isVimiumUIComponent then 0 else frameId +      chrome.runtime.sendMessage +        handler: "sendMessageToFrames", message: {name: "runInTopFrame", sourceFrameId, registryEntry} +    else if registryEntry.background +      chrome.runtime.sendMessage {handler: "runBackgroundCommand", registryEntry, count} +    else +      NormalModeCommands[registryEntry.command] count, {registryEntry} + +enterNormalMode = (count) -> +  new NormalMode +    indicator: "Normal mode (pass keys disabled)" +    exitOnEscape: true +    singleton: "enterNormalMode" +    count: count + +NormalModeCommands = +  # Scrolling. +  scrollToBottom: -> +    Marks.setPreviousPosition() +    Scroller.scrollTo "y", "max" +  scrollToTop: (count) -> +    Marks.setPreviousPosition() +    Scroller.scrollTo "y", (count - 1) * Settings.get("scrollStepSize") +  scrollToLeft: -> Scroller.scrollTo "x", 0 +  scrollToRight: -> Scroller.scrollTo "x", "max" +  scrollUp: (count) -> Scroller.scrollBy "y", -1 * Settings.get("scrollStepSize") * count +  scrollDown: (count) -> Scroller.scrollBy "y", Settings.get("scrollStepSize") * count +  scrollPageUp: (count) -> Scroller.scrollBy "y", "viewSize", -1/2 * count +  scrollPageDown: (count) -> Scroller.scrollBy "y", "viewSize", 1/2 * count +  scrollFullPageUp: (count) -> Scroller.scrollBy "y", "viewSize", -1 * count +  scrollFullPageDown: (count) -> Scroller.scrollBy "y", "viewSize", 1 * count +  scrollLeft: (count) -> Scroller.scrollBy "x", -1 * Settings.get("scrollStepSize") * count +  scrollRight: (count) -> Scroller.scrollBy "x", Settings.get("scrollStepSize") * count + +  # Page state. +  reload: (count, options) -> +    hard = options.registryEntry.options.hard ? false +    window.location.reload(hard) +  goBack: (count) -> history.go(-count) +  goForward: (count) -> history.go(count) + +  # Url manipulation. +  goUp: (count) -> +    url = window.location.href +    if (url[url.length - 1] == "/") +      url = url.substring(0, url.length - 1) + +    urlsplit = url.split("/") +    # make sure we haven't hit the base domain yet +    if (urlsplit.length > 3) +      urlsplit = urlsplit.slice(0, Math.max(3, urlsplit.length - count)) +      window.location.href = urlsplit.join('/') + +  goToRoot: -> +    window.location.href = window.location.origin + +  toggleViewSource: -> +    chrome.runtime.sendMessage { handler: "getCurrentTabUrl" }, (url) -> +      if (url.substr(0, 12) == "view-source:") +        url = url.substr(12, url.length - 12) +      else +        url = "view-source:" + url +      chrome.runtime.sendMessage {handler: "openUrlInNewTab", url} + +  copyCurrentUrl: -> +    chrome.runtime.sendMessage { handler: "getCurrentTabUrl" }, (url) -> +      HUD.copyToClipboard url +      url = url[0..25] + "...." if 28 < url.length +      HUD.showForDuration("Yanked #{url}", 2000) + +  openCopiedUrlInNewTab: (count) -> +    HUD.pasteFromClipboard (url) -> +      chrome.runtime.sendMessage { handler: "openUrlInNewTab", url, count } + +  openCopiedUrlInCurrentTab: -> +    HUD.pasteFromClipboard (url) -> +      chrome.runtime.sendMessage { handler: "openUrlInCurrentTab", url } + +  # Mode changes. +  enterInsertMode: -> +    # If a focusable element receives the focus, then we exit and leave the permanently-installed insert-mode +    # instance to take over. +    new InsertMode global: true, exitOnFocus: true + +  enterVisualMode: -> +    new VisualMode userLaunchedMode: true + +  enterVisualLineMode: -> +    new VisualLineMode userLaunchedMode: true + +  enterFindMode: -> +    Marks.setPreviousPosition() +    new FindMode() + +  # Find. +  performFind: (count) -> FindMode.findNext false for [0...count] by 1 +  performBackwardsFind: (count) -> FindMode.findNext true for [0...count] by 1 + +  # Misc. +  mainFrame: -> focusThisFrame highlight: true, forceFocusThisFrame: true +  showHelp: (sourceFrameId) -> HelpDialog.toggle {sourceFrameId, showAllCommandDetails: false} + +  passNextKey: (count, options) -> +    if options.registryEntry.options.normal +      enterNormalMode count +    else +      new PassNextKeyMode count + +  goPrevious: -> +    previousPatterns = Settings.get("previousPatterns") || "" +    previousStrings = previousPatterns.split(",").filter( (s) -> s.trim().length ) +    findAndFollowRel("prev") || findAndFollowLink(previousStrings) + +  goNext: -> +    nextPatterns = Settings.get("nextPatterns") || "" +    nextStrings = nextPatterns.split(",").filter( (s) -> s.trim().length ) +    findAndFollowRel("next") || findAndFollowLink(nextStrings) + +  focusInput: (count) -> +    # Focus the first input element on the page, and create overlays to highlight all the input elements, with +    # the currently-focused element highlighted specially. Tabbing will shift focus to the next input element. +    # Pressing any other key will remove the overlays and the special tab behavior. +    resultSet = DomUtils.evaluateXPath textInputXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE +    visibleInputs = +      for i in [0...resultSet.snapshotLength] by 1 +        element = resultSet.snapshotItem i +        continue unless DomUtils.getVisibleClientRect element, true +        { element, index: i, rect: Rect.copy element.getBoundingClientRect() } + +    visibleInputs.sort ({element: element1, index: i1}, {element: element2, index: i2}) -> +      # Put elements with a lower positive tabIndex first, keeping elements in DOM order. +      if element1.tabIndex > 0 +        if element2.tabIndex > 0 +          tabDifference = element1.tabIndex - element2.tabIndex +          if tabDifference != 0 +            tabDifference +          else +            i1 - i2 +        else +          -1 +      else if element2.tabIndex > 0 +        1 +      else +        i1 - i2 + +    if visibleInputs.length == 0 +      HUD.showForDuration("There are no inputs to focus.", 1000) +      return + +    # This is a hack to improve usability on the Vimium options page.  We prime the recently-focused input +    # to be the key-mappings input.  Arguably, this is the input that the user is most likely to use. +    recentlyFocusedElement = lastFocusedInput() + +    selectedInputIndex = +      if count == 1 +        # As the starting index, we pick that of the most recently focused input element (or 0). +        elements = visibleInputs.map (visibleInput) -> visibleInput.element +        Math.max 0, elements.indexOf recentlyFocusedElement +      else +        Math.min(count, visibleInputs.length) - 1 + +    hints = for tuple in visibleInputs +      hint = DomUtils.createElement "div" +      hint.className = "vimiumReset internalVimiumInputHint vimiumInputHint" + +      # minus 1 for the border +      hint.style.left = (tuple.rect.left - 1) + window.scrollX + "px" +      hint.style.top = (tuple.rect.top - 1) + window.scrollY  + "px" +      hint.style.width = tuple.rect.width + "px" +      hint.style.height = tuple.rect.height + "px" + +      hint + +    new FocusSelector hints, visibleInputs, selectedInputIndex + +if LinkHints? +  extend NormalModeCommands, +    "LinkHints.activateMode": LinkHints.activateMode.bind LinkHints +    "LinkHints.activateModeToOpenInNewTab": LinkHints.activateModeToOpenInNewTab.bind LinkHints +    "LinkHints.activateModeToOpenInNewForegroundTab": LinkHints.activateModeToOpenInNewForegroundTab.bind LinkHints +    "LinkHints.activateModeWithQueue": LinkHints.activateModeWithQueue.bind LinkHints +    "LinkHints.activateModeToOpenIncognito": LinkHints.activateModeToOpenIncognito.bind LinkHints +    "LinkHints.activateModeToDownloadLink": LinkHints.activateModeToDownloadLink.bind LinkHints +    "LinkHints.activateModeToCopyLinkUrl": LinkHints.activateModeToCopyLinkUrl.bind LinkHints + +if Vomnibar? +  extend NormalModeCommands, +    "Vomnibar.activate": Vomnibar.activate.bind Vomnibar +    "Vomnibar.activateInNewTab": Vomnibar.activateInNewTab.bind Vomnibar +    "Vomnibar.activateTabSelection": Vomnibar.activateTabSelection.bind Vomnibar +    "Vomnibar.activateBookmarks": Vomnibar.activateBookmarks.bind Vomnibar +    "Vomnibar.activateBookmarksInNewTab": Vomnibar.activateBookmarksInNewTab.bind Vomnibar +    "Vomnibar.activateEditUrl": Vomnibar.activateEditUrl.bind Vomnibar +    "Vomnibar.activateEditUrlInNewTab": Vomnibar.activateEditUrlInNewTab.bind Vomnibar + +if Marks? +  extend NormalModeCommands, +    "Marks.activateCreateMode": Marks.activateCreateMode.bind Marks +    "Marks.activateGotoMode": Marks.activateGotoMode.bind Marks + +# The types in <input type="..."> that we consider for focusInput command. Right now this is recalculated in +# each content script. Alternatively we could calculate it once in the background page and use a request to +# fetch it each time. +# Should we include the HTML5 date pickers here? + +# The corresponding XPath for such elements. +textInputXPath = (-> +  textInputTypes = [ "text", "search", "email", "url", "number", "password", "date", "tel" ] +  inputElements = ["input[" + +    "(" + textInputTypes.map((type) -> '@type="' + type + '"').join(" or ") + "or not(@type))" + +    " and not(@disabled or @readonly)]", +    "textarea", "*[@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"] +  DomUtils?.makeXPath(inputElements) +)() + +# used by the findAndFollow* functions. +followLink = (linkElement) -> +  if (linkElement.nodeName.toLowerCase() == "link") +    window.location.href = linkElement.href +  else +    # if we can click on it, don't simply set location.href: some next/prev links are meant to trigger AJAX +    # calls, like the 'more' button on GitHub's newsfeed. +    linkElement.scrollIntoView() +    DomUtils.simulateClick(linkElement) + +# +# Find and follow a link which matches any one of a list of strings. If there are multiple such links, they +# are prioritized for shortness, by their position in :linkStrings, how far down the page they are located, +# and finally by whether the match is exact. Practically speaking, this means we favor 'next page' over 'the +# next big thing', and 'more' over 'nextcompany', even if 'next' occurs before 'more' in :linkStrings. +# +findAndFollowLink = (linkStrings) -> +  linksXPath = DomUtils.makeXPath(["a", "*[@onclick or @role='link' or contains(@class, 'button')]"]) +  links = DomUtils.evaluateXPath(linksXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) +  candidateLinks = [] + +  # at the end of this loop, candidateLinks will contain all visible links that match our patterns +  # links lower in the page are more likely to be the ones we want, so we loop through the snapshot backwards +  for i in [(links.snapshotLength - 1)..0] by -1 +    link = links.snapshotItem(i) + +    # ensure link is visible (we don't mind if it is scrolled offscreen) +    boundingClientRect = link.getBoundingClientRect() +    if (boundingClientRect.width == 0 || boundingClientRect.height == 0) +      continue +    computedStyle = window.getComputedStyle(link, null) +    if (computedStyle.getPropertyValue("visibility") != "visible" || +        computedStyle.getPropertyValue("display") == "none") +      continue + +    linkMatches = false +    for linkString in linkStrings +      if link.innerText.toLowerCase().indexOf(linkString) != -1 || +          0 <= link.value?.indexOf? linkString +        linkMatches = true +        break +    continue unless linkMatches + +    candidateLinks.push(link) + +  return if (candidateLinks.length == 0) + +  for link in candidateLinks +    link.wordCount = link.innerText.trim().split(/\s+/).length + +  # We can use this trick to ensure that Array.sort is stable. We need this property to retain the reverse +  # in-page order of the links. + +  candidateLinks.forEach((a,i) -> a.originalIndex = i) + +  # favor shorter links, and ignore those that are more than one word longer than the shortest link +  candidateLinks = +    candidateLinks +      .sort((a, b) -> +        if (a.wordCount == b.wordCount) then a.originalIndex - b.originalIndex else a.wordCount - b.wordCount +      ) +      .filter((a) -> a.wordCount <= candidateLinks[0].wordCount + 1) + +  for linkString in linkStrings +    exactWordRegex = +      if /\b/.test(linkString[0]) or /\b/.test(linkString[linkString.length - 1]) +        new RegExp "\\b" + linkString + "\\b", "i" +      else +        new RegExp linkString, "i" +    for candidateLink in candidateLinks +      if exactWordRegex.test(candidateLink.innerText) || +          (candidateLink.value && exactWordRegex.test(candidateLink.value)) +        followLink(candidateLink) +        return true +  false + +findAndFollowRel = (value) -> +  relTags = ["link", "a", "area"] +  for tag in relTags +    elements = document.getElementsByTagName(tag) +    for element in elements +      if (element.hasAttribute("rel") && element.rel.toLowerCase() == value) +        followLink(element) +        return true + +class FocusSelector extends Mode +  constructor: (hints, visibleInputs, selectedInputIndex) -> +    super +      name: "focus-selector" +      exitOnClick: true +      keydown: (event) => +        if event.key == "Tab" +          hints[selectedInputIndex].classList.remove 'internalVimiumSelectedInputHint' +          selectedInputIndex += hints.length + (if event.shiftKey then -1 else 1) +          selectedInputIndex %= hints.length +          hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' +          DomUtils.simulateSelect visibleInputs[selectedInputIndex].element +          @suppressEvent +        else unless event.key == "Shift" +          @exit() +          # Give the new mode the opportunity to handle the event. +          @restartBubbling + +    @hintContainingDiv = DomUtils.addElementList hints, +      id: "vimiumInputMarkerContainer" +      className: "vimiumReset" + +    DomUtils.simulateSelect visibleInputs[selectedInputIndex].element +    if visibleInputs.length == 1 +      @exit() +      return +    else +      hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' + +  exit: -> +    super() +    DomUtils.removeElement @hintContainingDiv +    if document.activeElement and DomUtils.isEditable document.activeElement +      new InsertMode +        singleton: "post-find-mode/focus-input" +        targetElement: document.activeElement +        indicator: false + +root = exports ? (window.root ?= {}) +root.NormalMode = NormalMode +root.NormalModeCommands = NormalModeCommands +extend window, root unless exports? diff --git a/content_scripts/mode_visual.coffee b/content_scripts/mode_visual.coffee index 1c05cd85..4c6578cd 100644 --- a/content_scripts/mode_visual.coffee +++ b/content_scripts/mode_visual.coffee @@ -70,7 +70,7 @@ class Movement      else        @selection.modify @alterMethod, direction, granularity -  # Return a simple camparable value which depends on various aspects of the selection.  This is used to +  # Return a simple comparable value which depends on various aspects of the selection.  This is used to    # detect, after a movement, whether the selection has changed.    hashSelection: ->      range = @selection.getRangeAt(0) @@ -159,7 +159,7 @@ class Movement    # Scroll the focus into view.    scrollIntoView: -> -    unless @selection.type == "None" +    unless DomUtils.getSelectionType(@selection) == "None"        elementWithFocus = DomUtils.getElementWithFocus @selection, @getDirection() == backward        Scroller.scrollIntoView elementWithFocus if elementWithFocus @@ -240,7 +240,7 @@ class VisualMode extends KeyHandlerMode        commandHandler: @commandHandler.bind this      # If there was a range selection when the user lanuched visual mode, then we retain the selection on exit. -    @shouldRetainSelectionOnExit = @options.userLaunchedMode and @selection.type == "Range" +    @shouldRetainSelectionOnExit = @options.userLaunchedMode and DomUtils.getSelectionType(@selection) == "Range"      @onExit (event = null) =>        if @shouldRetainSelectionOnExit @@ -258,7 +258,7 @@ class VisualMode extends KeyHandlerMode        _name: "#{@id}/enter/click"        # Yank on <Enter>.        keypress: (event) => -        if event.keyCode == keyCodes.enter +        if event.key == "Enter"            unless event.metaKey or event.ctrlKey or event.altKey or event.shiftKey              @yank()              return @suppressEvent @@ -269,7 +269,7 @@ class VisualMode extends KeyHandlerMode      # Establish or use the initial selection.  If that's not possible, then enter caret mode.      unless @name == "caret" -      if @selection.type in [ "Caret", "Range" ] +      if DomUtils.getSelectionType(@selection) in [ "Caret", "Range" ]          selectionRect = @selection.getRangeAt(0).getBoundingClientRect()          if window.vimiumDomTestsAreRunning            # We're running the DOM tests, where getBoundingClientRect() isn't available. @@ -277,7 +277,7 @@ class VisualMode extends KeyHandlerMode          selectionRect = Rect.intersect selectionRect, Rect.create 0, 0, window.innerWidth, window.innerHeight          if selectionRect.height >= 0 and selectionRect.width >= 0            # The selection is visible in the current viewport. -          if @selection.type == "Caret" +          if DomUtils.getSelectionType(@selection) == "Caret"              # The caret is in the viewport. Make make it visible.              @movement.extendByOneCharacter(forward) or @movement.extendByOneCharacter backward          else @@ -285,7 +285,7 @@ class VisualMode extends KeyHandlerMode            # more likely to be interested in visible content.            @selection.removeAllRanges() -      if @selection.type != "Range" and @name != "caret" +      if DomUtils.getSelectionType(@selection) != "Range" and @name != "caret"          new CaretMode          HUD.showForDuration "No usable selection, entering caret mode...", 2500 @@ -312,7 +312,7 @@ class VisualMode extends KeyHandlerMode    yank: (args = {}) ->      @yankedText = @selection.toString()      @exit() -    chrome.runtime.sendMessage handler: "copyToClipboard", data: @yankedText +    HUD.copyToClipboard @yankedText      message = @yankedText.replace /\s+/g, " "      message = message[...12] + "..." if 15 < @yankedText.length @@ -341,10 +341,10 @@ class CaretMode extends VisualMode      super extend options, name: "caret", indicator: "Caret mode", alterMethod: "move"      # Establish the initial caret. -    switch @selection.type +    switch DomUtils.getSelectionType(@selection)        when "None"          @establishInitialSelectionAnchor() -        if @selection.type == "None" +        if DomUtils.getSelectionType(@selection) == "None"            @exit()            HUD.showForDuration "Create a selection before entering visual mode.", 2500            return @@ -380,6 +380,7 @@ class CaretMode extends VisualMode            return true      false -root = exports ? window +root = exports ? (window.root ?= {})  root.VisualMode = VisualMode  root.VisualLineMode = VisualLineMode +extend window, root unless exports? diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 3a1b3772..f65062e4 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -95,7 +95,14 @@ findScrollableElement = (element, direction, amount, factor) ->  # On some pages, the scrolling element is not actually scrollable.  Here, we search the document for the  # largest visible element which does scroll vertically. This is used to initialize activatedElement. See  # #1358. -firstScrollableElement = (element=getScrollingElement()) -> +firstScrollableElement = (element = null) -> +  unless element +    scrollingElement = getScrollingElement() +    if doesScroll(scrollingElement, "y", 1, 1) or doesScroll(scrollingElement, "y", -1, 1) +      return scrollingElement +    else +      element = document.body ? getScrollingElement() +    if doesScroll(element, "y", 1, 1) or doesScroll(element, "y", -1, 1)      element    else @@ -128,9 +135,11 @@ checkVisibility = (element) ->  CoreScroller =    init: ->      @time = 0 -    @lastEvent = null -    @keyIsDown = false +    @lastEvent = @keyIsDown = null +    @installCanceEventListener() +  # This installs listeners for events which should cancel smooth scrolling. +  installCanceEventListener: ->      # NOTE(smblott) With extreme keyboard configurations, Chrome sometimes does not get a keyup event for      # every keydown, in which case tapping "j" scrolls indefinitely.  This appears to be a Chrome/OS/XOrg bug      # of some kind.  See #1549. @@ -141,11 +150,11 @@ CoreScroller =            @keyIsDown = true            @time += 1 unless event.repeat            @lastEvent = event -      keyup: => +      keyup: (event) =>          handlerStack.alwaysContinueBubbling =>            @keyIsDown = false            @time += 1 -      blur: => +      blur: (event) =>          handlerStack.alwaysContinueBubbling =>            @time += 1 if event.target == window @@ -175,7 +184,7 @@ CoreScroller =      return if @lastEvent?.repeat      activationTime = ++@time -    myKeyIsStillDown = => @time == activationTime and @keyIsDown +    myKeyIsStillDown = => @time == activationTime and @keyIsDown ? true      # Store amount's sign and make amount positive; the arithmetic is clearer when amount is positive.      sign = getSign amount @@ -188,6 +197,7 @@ CoreScroller =      totalElapsed = 0.0      calibration = 1.0      previousTimestamp = null +    cancelEventListener = @installCanceEventListener()      animate = (timestamp) =>        previousTimestamp ?= timestamp @@ -215,13 +225,14 @@ CoreScroller =          requestAnimationFrame animate        else          # We're done. +        handlerStack.remove cancelEventListener          checkVisibility element      # If we've been asked not to be continuous, then we advance time, so the myKeyIsStillDown test always      # fails.      ++@time unless continuous -    # Launch animator. +    # Start scrolling.      requestAnimationFrame animate  # Scroller contains the two main scroll functions which are used by clients. @@ -297,5 +308,6 @@ Scroller =          element = findScrollableElement element, "x", amount, 1          CoreScroller.scroll element, "x", amount, false -root = exports ? window +root = exports ? (window.root ?= {})  root.Scroller = Scroller +extend window, root unless exports? diff --git a/content_scripts/ui_component.coffee b/content_scripts/ui_component.coffee index 203f0c8c..c71bfb35 100644 --- a/content_scripts/ui_component.coffee +++ b/content_scripts/ui_component.coffee @@ -96,5 +96,6 @@ class UIComponent          @options = null          @postMessage "hidden" # Inform the UI component that it is hidden. -root = exports ? window +root = exports ? (window.root ?= {})  root.UIComponent = UIComponent +extend window, root unless exports? diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css index 3e8f65d6..54256199 100644 --- a/content_scripts/vimium.css +++ b/content_scripts/vimium.css @@ -80,7 +80,7 @@ div.internalVimiumHintMarker {    overflow: hidden;    font-size: 11px;    padding: 1px 3px 0px 3px; -  background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785), color-stop(100%,#FFC542)); +  background: linear-gradient(to bottom, #FFF785 0%,#FFC542 100%);    border: solid 1px #C38A22;    border-radius: 3px;    box-shadow: 0px 3px 7px 0px rgba(0, 0, 0, 0.3); diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 21826944..432fa7a2 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -2,6 +2,12 @@  # This content script must be run prior to domReady so that we perform some operations very early.  # +root = exports ? (window.root ?= {}) +# On Firefox, sometimes the variables assigned to window are lost (bug 1408996), so we reinstall them. +# NOTE(mrmr1993): This bug leads to catastrophic failure (ie. nothing works and errors abound). +DomUtils.documentReady -> +  root.extend window, root unless extend? +  isEnabledForUrl = true  isIncognitoMode = chrome.extension.inIncognitoContext  normalMode = null @@ -10,25 +16,12 @@ normalMode = null  windowIsFocused = do ->    windowHasFocus = null    DomUtils.documentReady -> windowHasFocus = document.hasFocus() -  window.addEventListener "focus", (event) -> windowHasFocus = true if event.target == window; true -  window.addEventListener "blur", (event) -> windowHasFocus = false if event.target == window; true +  window.addEventListener "focus", forTrusted (event) -> +    windowHasFocus = true if event.target == window; true +  window.addEventListener "blur", forTrusted (event) -> +    windowHasFocus = false if event.target == window; true    -> windowHasFocus -# The types in <input type="..."> that we consider for focusInput command. Right now this is recalculated in -# each content script. Alternatively we could calculate it once in the background page and use a request to -# fetch it each time. -# Should we include the HTML5 date pickers here? - -# The corresponding XPath for such elements. -textInputXPath = (-> -  textInputTypes = [ "text", "search", "email", "url", "number", "password", "date", "tel" ] -  inputElements = ["input[" + -    "(" + textInputTypes.map((type) -> '@type="' + type + '"').join(" or ") + "or not(@type))" + -    " and not(@disabled or @readonly)]", -    "textarea", "*[@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"] -  DomUtils.makeXPath(inputElements) -)() -  # This is set by Frame.registerFrameId(). A frameId of 0 indicates that this is the top frame in the tab.  frameId = null @@ -109,47 +102,13 @@ handlerStack.push          target = target.parentElement      true -class NormalMode extends KeyHandlerMode -  constructor: (options = {}) -> -    super extend options, -      name: "normal" -      indicator: false # There is no mode indicator in normal mode. -      commandHandler: @commandHandler.bind this - -    chrome.storage.local.get "normalModeKeyStateMapping", (items) => -      @setKeyMapping items.normalModeKeyStateMapping - -    chrome.storage.onChanged.addListener (changes, area) => -      if area == "local" and changes.normalModeKeyStateMapping?.newValue -        @setKeyMapping changes.normalModeKeyStateMapping.newValue - -    # Initialize components which normal mode depends upon. -    Scroller.init() -    FindModeHistory.init() - -  commandHandler: ({command: registryEntry, count}) -> -    count *= registryEntry.options.count ? 1 -    count = 1 if registryEntry.noRepeat - -    if registryEntry.repeatLimit? and registryEntry.repeatLimit < count -      return unless confirm """ -        You have asked Vimium to perform #{count} repetitions of the command: #{registryEntry.description}.\n -        Are you sure you want to continue?""" - -    if registryEntry.topFrame -      # We never return to a UI-component frame (e.g. the help dialog), it might have lost the focus. -      sourceFrameId = if window.isVimiumUIComponent then 0 else frameId -      chrome.runtime.sendMessage -        handler: "sendMessageToFrames", message: {name: "runInTopFrame", sourceFrameId, registryEntry} -    else if registryEntry.background -      chrome.runtime.sendMessage {handler: "runBackgroundCommand", registryEntry, count} -    else -      Utils.invokeCommandString registryEntry.command, count, {registryEntry} -  installModes = ->    # Install the permanent modes. The permanently-installed insert mode tracks focus/blur events, and    # activates/deactivates itself accordingly.    normalMode = new NormalMode +  # Initialize components upon which normal mode depends. +  Scroller.init() +  FindModeHistory.init()    new InsertMode permanent: true    new GrabBackFocus if isEnabledForUrl    normalMode # Return the normalMode object (for the tests). @@ -157,9 +116,14 @@ installModes = ->  initializeOnEnabledStateKnown = (isEnabledForUrl) ->    installModes() unless normalMode    if isEnabledForUrl -    # We only initialize (and activate) the Vomnibar in the top frame.  Also, we do not initialize the -    # Vomnibar until we know that Vimium is enabled.  Thereafter, there's no more initialization to do. -    DomUtils.documentComplete Vomnibar.init.bind Vomnibar if DomUtils.isTopFrame() +    unless Utils.isFirefox() and document.documentElement.namespaceURI != "http://www.w3.org/1999/xhtml" +      # We only initialize (and activate) the Vomnibar in the top frame.  Also, we do not initialize the +      # Vomnibar until we know that Vimium is enabled.  Thereafter, there's no more initialization to do. +      # +      # NOTE(mrmr1993): In XML documents on Firefox, injecting HTML into the DOM breaks the rendering, so we +      # lazy load the Vomnibar. This comes with the expected issues, but is better than breaking all XML +      # documents. +      DomUtils.documentComplete Vomnibar.init.bind Vomnibar if DomUtils.isTopFrame()      initializeOnEnabledStateKnown = ->  # @@ -178,7 +142,7 @@ initializePreDomReady = ->      frameFocused: -> # A frame has received the focus; we don't care here (UI components handle this).      checkEnabledAfterURLChange: checkEnabledAfterURLChange      runInTopFrame: ({sourceFrameId, registryEntry}) -> -      Utils.invokeCommandString registryEntry.command, sourceFrameId, registryEntry if DomUtils.isTopFrame() +      NormalModeCommands[registryEntry.command] sourceFrameId, registryEntry if DomUtils.isTopFrame()      linkHintsMessage: (request) -> HintCoordinator[request.messageType] request    chrome.runtime.onMessage.addListener (request, sender, sendResponse) -> @@ -192,9 +156,10 @@ initializePreDomReady = ->  # Wrapper to install event listeners.  Syntactic sugar.  installListener = (element, event, callback) -> -  element.addEventListener(event, -> +  element.addEventListener(event, forTrusted(-> +    root.extend window, root unless extend? # See #2800.      if isEnabledForUrl then callback.apply(this, arguments) else true -  , true) +  ), true)  #  # Installing or uninstalling listeners is error prone. Instead we elect to check isEnabledForUrl each time so @@ -215,7 +180,7 @@ installListeners = Utils.makeIdempotent ->  # - Tell the background page this frame's URL.  # - Check if we should be enabled.  # -onFocus = (event) -> +onFocus = forTrusted (event) ->    if event.target == window      chrome.runtime.sendMessage handler: "frameFocused"      checkIfEnabledForUrl true @@ -223,7 +188,7 @@ onFocus = (event) ->  # We install these listeners directly (that is, we don't use installListener) because we still need to receive  # events when Vimium is not enabled.  window.addEventListener "focus", onFocus -window.addEventListener "hashchange", onFocus +window.addEventListener "hashchange", checkEnabledAfterURLChange  initializeOnDomReady = ->    # Tell the background page we're in the domReady state. @@ -237,7 +202,7 @@ Frame =    postMessage: (handler, request = {}) -> @port.postMessage extend request, {handler}    linkHintsMessage: (request) -> HintCoordinator[request.messageType] request    registerFrameId: ({chromeFrameId}) -> -    frameId = window.frameId = chromeFrameId +    frameId = root.frameId = window.frameId = chromeFrameId      # We register a frame immediately only if it is focused or its window isn't tiny.  We register tiny      # frames later, when necessary.  This affects focusFrame() and link hints.      if windowIsFocused() or not DomUtils.windowIsTooSmall() @@ -247,20 +212,21 @@ Frame =          window.removeEventListener "focus", focusHandler          window.removeEventListener "resize", resizeHandler          Frame.postMessage "registerFrame" -      window.addEventListener "focus", focusHandler = -> +      window.addEventListener "focus", focusHandler = forTrusted (event) ->          postRegisterFrame() if event.target == window -      window.addEventListener "resize", resizeHandler = -> +      window.addEventListener "resize", resizeHandler = forTrusted (event) ->          postRegisterFrame() unless DomUtils.windowIsTooSmall()    init: ->      @port = chrome.runtime.connect name: "frames"      @port.onMessage.addListener (request) => +      root.extend window, root unless extend? # See #2800 and #2831.        (@listeners[request.handler] ? this[request.handler]) request      # We disable the content scripts when we lose contact with the background page, or on unload.      @port.onDisconnect.addListener disconnect = Utils.makeIdempotent => @disconnect() -    window.addEventListener "unload", disconnect +    window.addEventListener "unload", forTrusted disconnect    disconnect: ->      try @postMessage "unregisterFrame" @@ -272,7 +238,7 @@ Frame =      handlerStack.reset()      isEnabledForUrl = false      window.removeEventListener "focus", onFocus -    window.removeEventListener "hashchange", onFocus +    window.removeEventListener "hashchange", checkEnabledAfterURLChange  setScrollPosition = ({ scrollX, scrollY }) ->    DomUtils.documentReady -> @@ -314,170 +280,30 @@ focusThisFrame = (request) ->        chrome.runtime.sendMessage handler: "nextFrame"        return    window.focus() +  # On Firefox, window.focus doesn't always draw focus back from a child frame (bug 554039). +  # We blur the active element if it is an iframe, which gives the window back focus as intended. +  document.activeElement.blur() if document.activeElement.tagName.toLowerCase() == "iframe"    flashFrame() if request.highlight -extend window, -  scrollToBottom: -> -    Marks.setPreviousPosition() -    Scroller.scrollTo "y", "max" -  scrollToTop: (count) -> -    Marks.setPreviousPosition() -    Scroller.scrollTo "y", (count - 1) * Settings.get("scrollStepSize") -  scrollToLeft: -> Scroller.scrollTo "x", 0 -  scrollToRight: -> Scroller.scrollTo "x", "max" -  scrollUp: (count) -> Scroller.scrollBy "y", -1 * Settings.get("scrollStepSize") * count -  scrollDown: (count) -> Scroller.scrollBy "y", Settings.get("scrollStepSize") * count -  scrollPageUp: (count) -> Scroller.scrollBy "y", "viewSize", -1/2 * count -  scrollPageDown: (count) -> Scroller.scrollBy "y", "viewSize", 1/2 * count -  scrollFullPageUp: (count) -> Scroller.scrollBy "y", "viewSize", -1 * count -  scrollFullPageDown: (count) -> Scroller.scrollBy "y", "viewSize", 1 * count -  scrollLeft: (count) -> Scroller.scrollBy "x", -1 * Settings.get("scrollStepSize") * count -  scrollRight: (count) -> Scroller.scrollBy "x", Settings.get("scrollStepSize") * count - -extend window, -  reload: -> window.location.reload() -  goBack: (count) -> history.go(-count) -  goForward: (count) -> history.go(count) - -  goUp: (count) -> -    url = window.location.href -    if (url[url.length - 1] == "/") -      url = url.substring(0, url.length - 1) - -    urlsplit = url.split("/") -    # make sure we haven't hit the base domain yet -    if (urlsplit.length > 3) -      urlsplit = urlsplit.slice(0, Math.max(3, urlsplit.length - count)) -      window.location.href = urlsplit.join('/') - -  goToRoot: -> -    window.location.href = window.location.origin - -  mainFrame: -> focusThisFrame highlight: true, forceFocusThisFrame: true - -  toggleViewSource: -> -    chrome.runtime.sendMessage { handler: "getCurrentTabUrl" }, (url) -> -      if (url.substr(0, 12) == "view-source:") -        url = url.substr(12, url.length - 12) -      else -        url = "view-source:" + url -      chrome.runtime.sendMessage {handler: "openUrlInNewTab", url} - -  copyCurrentUrl: -> -    # TODO(ilya): When the following bug is fixed, revisit this approach of sending back to the background -    # page to copy. -    # http://code.google.com/p/chromium/issues/detail?id=55188 -    chrome.runtime.sendMessage { handler: "getCurrentTabUrl" }, (url) -> -      chrome.runtime.sendMessage { handler: "copyToClipboard", data: url } -      url = url[0..25] + "...." if 28 < url.length -      HUD.showForDuration("Yanked #{url}", 2000) - -  enterInsertMode: -> -    # If a focusable element receives the focus, then we exit and leave the permanently-installed insert-mode -    # instance to take over. -    new InsertMode global: true, exitOnFocus: true - -  enterVisualMode: -> -    new VisualMode userLaunchedMode: true - -  enterVisualLineMode: -> -    new VisualLineMode userLaunchedMode: true - -  passNextKey: (count) -> -    new PassNextKeyMode count - -  focusInput: do -> -    # Track the most recently focused input element. -    recentlyFocusedElement = null -    window.addEventListener "focus", -      (event) -> recentlyFocusedElement = event.target if DomUtils.isEditable event.target -    , true - -    (count) -> -      mode = InsertMode -      # Focus the first input element on the page, and create overlays to highlight all the input elements, with -      # the currently-focused element highlighted specially. Tabbing will shift focus to the next input element. -      # Pressing any other key will remove the overlays and the special tab behavior. -      # The mode argument is the mode to enter once an input is selected. -      resultSet = DomUtils.evaluateXPath textInputXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE -      visibleInputs = -        for i in [0...resultSet.snapshotLength] by 1 -          element = resultSet.snapshotItem i -          continue unless DomUtils.getVisibleClientRect element, true -          { element, rect: Rect.copy element.getBoundingClientRect() } - -      if visibleInputs.length == 0 -        HUD.showForDuration("There are no inputs to focus.", 1000) -        return - -      # This is a hack to improve usability on the Vimium options page.  We prime the recently-focused input -      # to be the key-mappings input.  Arguably, this is the input that the user is most likely to use. -      recentlyFocusedElement ?= document.getElementById "keyMappings" if window.isVimiumOptionsPage - -      selectedInputIndex = -        if count == 1 -          # As the starting index, we pick that of the most recently focused input element (or 0). -          elements = visibleInputs.map (visibleInput) -> visibleInput.element -          Math.max 0, elements.indexOf recentlyFocusedElement -        else -          Math.min(count, visibleInputs.length) - 1 - -      hints = for tuple in visibleInputs -        hint = DomUtils.createElement "div" -        hint.className = "vimiumReset internalVimiumInputHint vimiumInputHint" - -        # minus 1 for the border -        hint.style.left = (tuple.rect.left - 1) + window.scrollX + "px" -        hint.style.top = (tuple.rect.top - 1) + window.scrollY  + "px" -        hint.style.width = tuple.rect.width + "px" -        hint.style.height = tuple.rect.height + "px" - -        hint - -      new class FocusSelector extends Mode -        constructor: -> -          super -            name: "focus-selector" -            exitOnClick: true -            keydown: (event) => -              if event.keyCode == KeyboardUtils.keyCodes.tab -                hints[selectedInputIndex].classList.remove 'internalVimiumSelectedInputHint' -                selectedInputIndex += hints.length + (if event.shiftKey then -1 else 1) -                selectedInputIndex %= hints.length -                hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' -                DomUtils.simulateSelect visibleInputs[selectedInputIndex].element -                @suppressEvent -              else unless event.keyCode == KeyboardUtils.keyCodes.shiftKey -                @exit() -                # Give the new mode the opportunity to handle the event. -                @restartBubbling - -          @hintContainingDiv = DomUtils.addElementList hints, -            id: "vimiumInputMarkerContainer" -            className: "vimiumReset" - -          DomUtils.simulateSelect visibleInputs[selectedInputIndex].element -          if visibleInputs.length == 1 -            @exit() -            return -          else -            hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' - -        exit: -> -          super() -          DomUtils.removeElement @hintContainingDiv -          if mode and document.activeElement and DomUtils.isEditable document.activeElement -            new mode -              singleton: "post-find-mode/focus-input" -              targetElement: document.activeElement -              indicator: false +# Used by focusInput command. +root.lastFocusedInput = do -> +  # Track the most recently focused input element. +  recentlyFocusedElement = null +  window.addEventListener "focus", +    forTrusted (event) -> +      DomUtils = window.DomUtils ? root.DomUtils # Workaround FF bug 1408996. +      if DomUtils.isEditable event.target +        recentlyFocusedElement = event.target +  , true +  -> recentlyFocusedElement  # Checks if Vimium should be enabled or not in this frame.  As a side effect, it also informs the background  # page whether this frame has the focus, allowing the background page to track the active frame's URL and set  # the page icon.  checkIfEnabledForUrl = do ->    Frame.addEventListener "isEnabledForUrl", (response) -> -    {isEnabledForUrl, passKeys, frameIsFocused} = response +    {isEnabledForUrl, passKeys, frameIsFocused, isFirefox} = response +    Utils.isFirefox = -> isFirefox      initializeOnEnabledStateKnown isEnabledForUrl      normalMode.setPassKeys passKeys      # Hide the HUD if we're not enabled. @@ -488,168 +314,11 @@ checkIfEnabledForUrl = do ->  # When we're informed by the background page that a URL in this tab has changed, we check if we have the  # correct enabled state (but only if this frame has the focus). -checkEnabledAfterURLChange = -> +checkEnabledAfterURLChange = forTrusted ->    checkIfEnabledForUrl() if windowIsFocused() -handleEscapeForFindMode = -> -  document.body.classList.remove("vimiumFindMode") -  # removing the class does not re-color existing selections. we recreate the current selection so it reverts -  # back to the default color. -  selection = window.getSelection() -  unless selection.isCollapsed -    range = window.getSelection().getRangeAt(0) -    window.getSelection().removeAllRanges() -    window.getSelection().addRange(range) -  focusFoundLink() || selectFoundInputElement() - -# <esc> sends us into insert mode if possible, but <cr> does not. -# <esc> corresponds approximately to 'nevermind, I have found it already' while <cr> means 'I want to save -# this query and do more searches with it' -handleEnterForFindMode = -> -  focusFoundLink() -  document.body.classList.add("vimiumFindMode") -  FindMode.saveQuery() - -focusFoundLink = -> -  if (FindMode.query.hasResults) -    link = getLinkFromSelection() -    link.focus() if link - -selectFoundInputElement = -> -  # Since the last focused element might not be the one currently pointed to by find (e.g.  the current one -  # might be disabled and therefore unable to receive focus), we use the approximate heuristic of checking -  # that the last anchor node is an ancestor of our element. -  findModeAnchorNode = document.getSelection().anchorNode -  if (FindMode.query.hasResults && document.activeElement && -      DomUtils.isSelectable(document.activeElement) && -      DomUtils.isDOMDescendant(findModeAnchorNode, document.activeElement)) -    DomUtils.simulateSelect(document.activeElement) - -findAndFocus = (backwards) -> -  Marks.setPreviousPosition() -  FindMode.query.hasResults = FindMode.execute null, {backwards} - -  if FindMode.query.hasResults -    focusFoundLink() -    new PostFindMode() -  else -    HUD.showForDuration("No matches for '#{FindMode.query.rawQuery}'", 1000) - -performFind = (count) -> findAndFocus false for [0...count] by 1 -performBackwardsFind = (count) -> findAndFocus true for [0...count] by 1 - -getLinkFromSelection = -> -  node = window.getSelection().anchorNode -  while (node && node != document.body) -    return node if (node.nodeName.toLowerCase() == "a") -    node = node.parentNode -  null - -# used by the findAndFollow* functions. -followLink = (linkElement) -> -  if (linkElement.nodeName.toLowerCase() == "link") -    window.location.href = linkElement.href -  else -    # if we can click on it, don't simply set location.href: some next/prev links are meant to trigger AJAX -    # calls, like the 'more' button on GitHub's newsfeed. -    linkElement.scrollIntoView() -    DomUtils.simulateClick(linkElement) - -# -# Find and follow a link which matches any one of a list of strings. If there are multiple such links, they -# are prioritized for shortness, by their position in :linkStrings, how far down the page they are located, -# and finally by whether the match is exact. Practically speaking, this means we favor 'next page' over 'the -# next big thing', and 'more' over 'nextcompany', even if 'next' occurs before 'more' in :linkStrings. -# -findAndFollowLink = (linkStrings) -> -  linksXPath = DomUtils.makeXPath(["a", "*[@onclick or @role='link' or contains(@class, 'button')]"]) -  links = DomUtils.evaluateXPath(linksXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) -  candidateLinks = [] - -  # at the end of this loop, candidateLinks will contain all visible links that match our patterns -  # links lower in the page are more likely to be the ones we want, so we loop through the snapshot backwards -  for i in [(links.snapshotLength - 1)..0] by -1 -    link = links.snapshotItem(i) - -    # ensure link is visible (we don't mind if it is scrolled offscreen) -    boundingClientRect = link.getBoundingClientRect() -    if (boundingClientRect.width == 0 || boundingClientRect.height == 0) -      continue -    computedStyle = window.getComputedStyle(link, null) -    if (computedStyle.getPropertyValue("visibility") != "visible" || -        computedStyle.getPropertyValue("display") == "none") -      continue - -    linkMatches = false -    for linkString in linkStrings -      if link.innerText.toLowerCase().indexOf(linkString) != -1 || -          0 <= link.value?.indexOf? linkString -        linkMatches = true -        break -    continue unless linkMatches - -    candidateLinks.push(link) - -  return if (candidateLinks.length == 0) - -  for link in candidateLinks -    link.wordCount = link.innerText.trim().split(/\s+/).length - -  # We can use this trick to ensure that Array.sort is stable. We need this property to retain the reverse -  # in-page order of the links. - -  candidateLinks.forEach((a,i) -> a.originalIndex = i) - -  # favor shorter links, and ignore those that are more than one word longer than the shortest link -  candidateLinks = -    candidateLinks -      .sort((a, b) -> -        if (a.wordCount == b.wordCount) then a.originalIndex - b.originalIndex else a.wordCount - b.wordCount -      ) -      .filter((a) -> a.wordCount <= candidateLinks[0].wordCount + 1) - -  for linkString in linkStrings -    exactWordRegex = -      if /\b/.test(linkString[0]) or /\b/.test(linkString[linkString.length - 1]) -        new RegExp "\\b" + linkString + "\\b", "i" -      else -        new RegExp linkString, "i" -    for candidateLink in candidateLinks -      if exactWordRegex.test(candidateLink.innerText) || -          (candidateLink.value && exactWordRegex.test(candidateLink.value)) -        followLink(candidateLink) -        return true -  false - -findAndFollowRel = (value) -> -  relTags = ["link", "a", "area"] -  for tag in relTags -    elements = document.getElementsByTagName(tag) -    for element in elements -      if (element.hasAttribute("rel") && element.rel.toLowerCase() == value) -        followLink(element) -        return true - -window.goPrevious = -> -  previousPatterns = Settings.get("previousPatterns") || "" -  previousStrings = previousPatterns.split(",").filter( (s) -> s.trim().length ) -  findAndFollowRel("prev") || findAndFollowLink(previousStrings) - -window.goNext = -> -  nextPatterns = Settings.get("nextPatterns") || "" -  nextStrings = nextPatterns.split(",").filter( (s) -> s.trim().length ) -  findAndFollowRel("next") || findAndFollowLink(nextStrings) - -# Enters find mode.  Returns the new find-mode instance. -enterFindMode = -> -  Marks.setPreviousPosition() -  new FindMode() - -window.showHelp = (sourceFrameId) -> -  HelpDialog.toggle {sourceFrameId, showAllCommandDetails: false} -  # If we are in the help dialog iframe, then HelpDialog is already defined with the necessary functions. -window.HelpDialog ?= +root.HelpDialog ?=    helpUI: null    isShowing: -> @helpUI?.showing    abort: -> @helpUI.hide false if @isShowing() @@ -666,14 +335,13 @@ window.HelpDialog ?=  initializePreDomReady()  DomUtils.documentReady initializeOnDomReady -root = exports ? window  root.handlerStack = handlerStack  root.frameId = frameId  root.Frame = Frame  root.windowIsFocused = windowIsFocused  root.bgLog = bgLog -# These are exported for find mode and link-hints mode. -extend root, {handleEscapeForFindMode, handleEnterForFindMode, performFind, performBackwardsFind, -  enterFindMode, focusThisFrame} +# These are exported for normal mode and link-hints mode. +extend root, {focusThisFrame}  # These are exported only for the tests. -extend root, {installModes, installListeners} +extend root, {installModes} +extend window, root unless exports? diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee index 04499523..ad98aa48 100644 --- a/content_scripts/vomnibar.coffee +++ b/content_scripts/vomnibar.coffee @@ -53,10 +53,11 @@ Vomnibar =    #   selectFirst - Optional, boolean. Whether to select the first entry.    #   newTab      - Optional, boolean. Whether to open the result in a new tab.    open: (sourceFrameId, options) -> -    if @vomnibarUI? -      # The Vomnibar cannot coexist with the help dialog (it causes focus issues). -      HelpDialog.abort() -      @vomnibarUI.activate extend options, { name: "activate", sourceFrameId, focus: true } +    @init() +    # The Vomnibar cannot coexist with the help dialog (it causes focus issues). +    HelpDialog.abort() +    @vomnibarUI.activate extend options, { name: "activate", sourceFrameId, focus: true } -root = exports ? window +root = exports ? (window.root ?= {})  root.Vomnibar = Vomnibar +extend window, root unless exports? diff --git a/lib/clipboard.coffee b/lib/clipboard.coffee index af143dd9..a9e2e82e 100644 --- a/lib/clipboard.coffee +++ b/lib/clipboard.coffee @@ -1,8 +1,9 @@  Clipboard = -  _createTextArea: -> -    textArea = document.createElement "textarea" +  _createTextArea: (tagName = "textarea") -> +    textArea = document.createElement tagName      textArea.style.position = "absolute"      textArea.style.left = "-100%" +    textArea.contentEditable = "true"      textArea    # http://groups.google.com/group/chromium-extensions/browse_thread/thread/49027e7f3b04f68/f6ab2457dee5bf55 @@ -16,14 +17,15 @@ Clipboard =      document.body.removeChild(textArea)    paste: -> -    textArea = @_createTextArea() +    textArea = @_createTextArea "div" # Use a <div> so Firefox pastes rich text.      document.body.appendChild(textArea)      textArea.focus()      document.execCommand("Paste") -    value = textArea.value +    value = textArea.innerText      document.body.removeChild(textArea)      value -root = exports ? window +root = exports ? (window.root ?= {})  root.Clipboard = Clipboard +extend window, root unless exports? diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index 82c13287..67d5a44c 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -5,7 +5,7 @@ DomUtils =    documentReady: do ->      [isReady, callbacks] = [document.readyState != "loading", []]      unless isReady -      window.addEventListener "DOMContentLoaded", onDOMContentLoaded = -> +      window.addEventListener "DOMContentLoaded", onDOMContentLoaded = forTrusted ->          window.removeEventListener "DOMContentLoaded", onDOMContentLoaded          isReady = true          callback() for callback in callbacks @@ -16,7 +16,7 @@ DomUtils =    documentComplete: do ->      [isComplete, callbacks] = [document.readyState == "complete", []]      unless isComplete -      window.addEventListener "load", onLoad = -> +      window.addEventListener "load", onLoad = forTrusted ->          window.removeEventListener "load", onLoad          isComplete = true          callback() for callback in callbacks @@ -219,7 +219,7 @@ DomUtils =        node = selection.anchorNode        node and @isDOMDescendant element, node      else -      if selection.type == "Range" and selection.isCollapsed +      if DomUtils.getSelectionType(selection) == "Range" and selection.isCollapsed  	      # The selection is inside the Shadow DOM of a node. We can check the node it registers as being  	      # before, since this represents the node whose Shadow DOM it's inside.          containerNode = selection.anchorNode.childNodes[selection.anchorOffset] @@ -249,7 +249,11 @@ DomUtils =    simulateClick: (element, modifiers) ->      eventSequence = ["mouseover", "mousedown", "mouseup", "click"]      for event in eventSequence -      @simulateMouseEvent event, element, modifiers +      defaultActionShouldTrigger = @simulateMouseEvent event, element, modifiers +      if event == "click" and defaultActionShouldTrigger and Utils.isFirefox() +        # Firefox doesn't (currently) trigger the default action for modified keys. +        DomUtils.simulateClickDefaultAction element, modifiers +      defaultActionShouldTrigger # return the values returned by each @simulateMouseEvent call.    simulateMouseEvent: do ->      lastHoveredElement = undefined @@ -272,6 +276,29 @@ DomUtils =        # but Webkit will. Dispatching a click on an input box does not seem to focus it; we do that separately        element.dispatchEvent(mouseEvent) +  simulateClickDefaultAction: (element, modifiers = {}) -> +    return unless element.tagName?.toLowerCase() == "a" and element.href? + +    {ctrlKey, shiftKey, metaKey, altKey} = modifiers + +    # Mac uses a different new tab modifier (meta vs. ctrl). +    if KeyboardUtils.platform == "Mac" +      newTabModifier = metaKey == true and ctrlKey == false +    else +      newTabModifier = metaKey == false and ctrlKey == true + +    if newTabModifier +      # Open in new tab. Shift determines whether the tab is focused when created. Alt is ignored. +      chrome.runtime.sendMessage {handler: "openUrlInNewTab", url: element.href, active: +        shiftKey == true} +    else if shiftKey == true and metaKey == false and ctrlKey == false and altKey == false +      # Open in new window. +      chrome.runtime.sendMessage {handler: "openUrlInNewWindow", url: element.href} +    else if element.target == "_blank" +      chrome.runtime.sendMessage {handler: "openUrlInNewTab", url: element.href, active: true} + +    return +    addFlashRect: (rect) ->      flashEl = @createElement "div"      flashEl.classList.add "vimiumReset" @@ -289,11 +316,23 @@ DomUtils =      setTimeout((-> DomUtils.removeElement flashEl), 400)    getViewportTopLeft: -> -    if getComputedStyle(document.documentElement).position == "static" -      top: window.scrollY, left: window.scrollX +    box = document.documentElement +    style = getComputedStyle box +    rect = box.getBoundingClientRect() +    if style.position == "static" and not /content|paint|strict/.test(style.contain or "") +      # The margin is included in the client rect, so we need to subtract it back out. +      marginTop = parseInt style.marginTop +      marginLeft = parseInt style.marginLeft +      top: -rect.top + marginTop, left: -rect.left + marginLeft      else -      rect = document.documentElement.getBoundingClientRect() -      top: -rect.top, left: -rect.left +      if Utils.isFirefox() +        # These are always 0 for documentElement on Firefox, so we derive them from CSS border. +        clientTop = parseInt style.borderTopWidth +        clientLeft = parseInt style.borderLeftWidth +      else +        {clientTop, clientLeft} = box +      top: -rect.top - clientTop, left: -rect.left - clientLeft +    suppressPropagation: (event) ->      event.stopImmediatePropagation() @@ -302,20 +341,50 @@ DomUtils =      event.preventDefault()      @suppressPropagation(event) -  # Suppress the next keyup event for Escape. -  suppressKeyupAfterEscape: (handlerStack) -> -    handlerStack.push -      _name: "dom_utils/suppressKeyupAfterEscape" -      keyup: (event) -> -        return true unless KeyboardUtils.isEscape event -        @remove() -        false +  consumeKeyup: do -> +    handlerId = null + +    (event, callback = null, suppressPropagation) -> +      unless event.repeat +        handlerStack.remove handlerId if handlerId? +        code = event.code +        handlerId = handlerStack.push +          _name: "dom_utils/consumeKeyup" +          keyup: (event) -> +            return handlerStack.continueBubbling unless event.code == code +            @remove() +            if suppressPropagation +              DomUtils.suppressPropagation event +            else +              DomUtils.suppressEvent event +            handlerStack.continueBubbling +          # We cannot track keyup events if we lose the focus. +          blur: (event) -> +            @remove() if event.target == window +            handlerStack.continueBubbling +      callback?() +      if suppressPropagation +        DomUtils.suppressPropagation event +        handlerStack.suppressPropagation +      else +        DomUtils.suppressEvent event +        handlerStack.suppressEvent + +  # Polyfill for selection.type (which is not available in Firefox). +  getSelectionType: (selection = document.getSelection()) -> +    selection.type or do -> +      if selection.rangeCount == 0 +        "None" +      else if selection.isCollapsed +        "Caret" +      else +        "Range"    # Adapted from: http://roysharon.com/blog/37.    # This finds the element containing the selection focus.    getElementWithFocus: (selection, backwards) ->      r = t = selection.getRangeAt 0 -    if selection.type == "Range" +    if DomUtils.getSelectionType(selection) == "Range"        r = t.cloneRange()        r.collapse backwards      t = r.startContainer @@ -341,11 +410,20 @@ DomUtils =    # If the element is rendered in a shadow DOM via a <content> element, the <content> element will be    # returned, so the shadow DOM is traversed rather than passed over.    getContainingElement: (element) -> -    element.getDestinationInsertionPoints()[0] or element.parentElement +    element.getDestinationInsertionPoints?()[0] or element.parentElement    # This tests whether a window is too small to be useful.    windowIsTooSmall: ->      return window.innerWidth < 3 or window.innerHeight < 3 -root = exports ? window +  # Inject user styles manually. This is only necessary for our chrome-extension:// pages and frames. +  injectUserCss: -> +    Settings.onLoaded -> +      style = document.createElement "style" +      style.type = "text/css" +      style.textContent = Settings.get "userDefinedLinkHintCss" +      document.head.appendChild style + +root = exports ? (window.root ?= {})  root.DomUtils = DomUtils +extend window, root unless exports? diff --git a/lib/find_mode_history.coffee b/lib/find_mode_history.coffee index ff660bd2..93698266 100644 --- a/lib/find_mode_history.coffee +++ b/lib/find_mode_history.coffee @@ -46,5 +46,6 @@ FindModeHistory =    refreshRawQueryList: (query, rawQueryList) ->      ([ query ].concat rawQueryList.filter (q) => q != query)[0..@max] -root = exports ? window +root = exports ? (window.root ?= {})  root.FindModeHistory = FindModeHistory +extend window, root unless exports? diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee index 806b707f..a43fc356 100644 --- a/lib/handler_stack.coffee +++ b/lib/handler_stack.coffee @@ -1,4 +1,4 @@ -root = exports ? window +root = exports ? (window.root ?= {})  class HandlerStack    constructor: -> @@ -57,7 +57,10 @@ class HandlerStack          if result == @passEventToPage            return true          else if result == @suppressPropagation -          DomUtils.suppressPropagation event +          if type == "keydown" +            DomUtils.consumeKeyup event, null, true +          else +            DomUtils.suppressPropagation event            return false          else if result == @restartBubbling            return @bubbleEvent type, event @@ -65,7 +68,11 @@ class HandlerStack            true # Do nothing, but continue bubbling.          else            # result is @suppressEvent or falsy. -          DomUtils.suppressEvent event if @isChromeEvent event +          if @isChromeEvent event +            if type == "keydown" +              DomUtils.consumeKeyup event +            else +              DomUtils.suppressEvent event            return false      # None of our handlers care about this event, so pass it to the page. @@ -120,3 +127,4 @@ class HandlerStack  root.HandlerStack = HandlerStack  root.handlerStack = new HandlerStack() +extend window, root unless exports? diff --git a/lib/keyboard_utils.coffee b/lib/keyboard_utils.coffee index c49fb3f4..673289b9 100644 --- a/lib/keyboard_utils.coffee +++ b/lib/keyboard_utils.coffee @@ -1,26 +1,11 @@ -KeyboardUtils = -  keyCodes: -    { ESC: 27, backspace: 8, deleteKey: 46, enter: 13, ctrlEnter: 10, space: 32, shiftKey: 16, ctrlKey: 17, f1: 112, -    f12: 123, tab: 9, downArrow: 40, upArrow: 38 } +mapKeyRegistry = {} +# NOTE: "?" here for the tests. +Utils?.monitorChromeStorage "mapKeyRegistry", (value) => mapKeyRegistry = value +KeyboardUtils = +  # This maps event.key key names to Vimium key names.    keyNames: -    { 37: "left", 38: "up", 39: "right", 40: "down", 32: "space", 8: "backspace" } - -  # This is a mapping of the incorrect keyIdentifiers generated by Webkit on Windows during keydown events to -  # the correct identifiers, which are correctly generated on Mac. We require this mapping to properly handle -  # these keys on Windows. See https://bugs.webkit.org/show_bug.cgi?id=19906 for more details. -  keyIdentifierCorrectionMap: -    "U+00C0": ["U+0060", "U+007E"] # `~ -    "U+00BD": ["U+002D", "U+005F"] # -_ -    "U+00BB": ["U+003D", "U+002B"] # =+ -    "U+00DB": ["U+005B", "U+007B"] # [{ -    "U+00DD": ["U+005D", "U+007D"] # ]} -    "U+00DC": ["U+005C", "U+007C"] # \| -    "U+00BA": ["U+003B", "U+003A"] # ;: -    "U+00DE": ["U+0027", "U+0022"] # '" -    "U+00BC": ["U+002C", "U+003C"] # ,< -    "U+00BE": ["U+002E", "U+003E"] # .> -    "U+00BF": ["U+002F", "U+003F"] # /? +    "ArrowLeft": "left", "ArrowUp": "up", "ArrowRight": "right", "ArrowDown": "down", " ": "space"    init: ->      if (navigator.userAgent.indexOf("Mac") != -1) @@ -30,106 +15,95 @@ KeyboardUtils =      else        @platform = "Windows" -  # We are migrating from using event.keyIdentifier to using event.key.  For some period of time, we must -  # support both.  This wrapper can be removed once Chrome 52 is considered too old to support.    getKeyChar: (event) -> -    # We favor using event.keyIdentifier due to Chromium's currently (Chrome 51) incorrect implementataion of -    # event.key; see #2147. -    if event.keyIdentifier? -      @getKeyCharUsingKeyIdentifier event -    else -      @getKeyCharUsingKey event - -  getKeyCharUsingKey: (event) -> -    if event.keyCode of @keyNames -      @keyNames[event.keyCode] -    else if event.key.length == 1 -      event.key -    else if event.key.length == 2 and "F1" <= event.key <= "F9" -      event.key.toLowerCase() # F1 to F9. -    else if event.key.length == 3 and "F10" <= event.key <= "F12" -      event.key.toLowerCase() # F10 to F12. +    unless Settings.get "ignoreKeyboardLayout" +      key = event.key +    else unless event.code +      key = "" +    else if event.code[...6] == "Numpad" +      # We cannot correctly emulate the numpad, so fall back to event.key; see #2626. +      key = event.key      else +      # The logic here is from the vim-like-key-notation project (https://github.com/lydell/vim-like-key-notation). +      key = event.code +      key = key[3..] if key[...3] == "Key" +      # Translate some special keys to event.key-like strings and handle <Shift>. +      if @enUsTranslations[key] +        key = if event.shiftKey then @enUsTranslations[key][1] else @enUsTranslations[key][0] +      else if key.length == 1 and not event.shiftKey +        key = key.toLowerCase() + +    # It appears that key is not always defined (see #2453). +    unless key        "" +    else if key of @keyNames +      @keyNames[key] +    else if @isModifier event +      "" # Don't resolve modifier keys. +    else if key.length == 1 +      key +    else +      key.toLowerCase() -  getKeyCharUsingKeyIdentifier: (event) -> -    # Handle named keys. -    keyCode = event.keyCode -    if keyCode -      if keyCode of @keyNames -        return @keyNames[keyCode] -      # Function keys. -      if @keyCodes.f1 <= keyCode <= @keyCodes.f12 -        return "f" + (1 + keyCode - keyCodes.f1) - -    keyIdentifier = event.keyIdentifier - -    # Not a letter. -    if not keyIdentifier.startsWith "U+" -      return "" +  getKeyCharString: (event) -> +    if keyChar = @getKeyChar event +      modifiers = [] -    # On Windows, the keyIdentifiers for non-letter keys are incorrect. See -    # https://bugs.webkit.org/show_bug.cgi?id=19906 for more details. -    if ((@platform == "Windows" || @platform == "Linux") && @keyIdentifierCorrectionMap[keyIdentifier]) -      correctedIdentifiers = @keyIdentifierCorrectionMap[keyIdentifier] -      keyIdentifier = if event.shiftKey then correctedIdentifiers[1] else correctedIdentifiers[0] -    unicodeKeyInHex = "0x" + keyIdentifier.substring(2) -    character = String.fromCharCode(parseInt(unicodeKeyInHex)).toLowerCase() -    if event.shiftKey then character.toUpperCase() else character +      keyChar = keyChar.toUpperCase() if event.shiftKey and keyChar.length == 1 +      # These must be in alphabetical order (to match the sorted modifier order in Commands.normalizeKey). +      modifiers.push "a" if event.altKey +      modifiers.push "c" if event.ctrlKey +      modifiers.push "m" if event.metaKey -  isPrimaryModifierKey: (event) -> if (@platform == "Mac") then event.metaKey else event.ctrlKey +      keyChar = [modifiers..., keyChar].join "-" +      keyChar = "<#{keyChar}>" if 1 < keyChar.length +      keyChar = mapKeyRegistry[keyChar] ? keyChar +      keyChar    isEscape: do -> -    mapKeyRegistry = {} -    # NOTE: "?" here for the tests. -    Utils?.monitorChromeStorage "mapKeyRegistry", (value) => mapKeyRegistry = value +    useVimLikeEscape = true +    Utils.monitorChromeStorage "useVimLikeEscape", (value) -> useVimLikeEscape = value -    # TODO(smblott) Change this to use event.key.      (event) -> -      event.keyCode == @keyCodes.ESC || do => -        keyChar = @getKeyCharString event, true -        keyChar = mapKeyRegistry[keyChar] ? keyChar -        # <c-[> is mapped to Escape in Vim by default. -        keyChar == "<c-[>" - -  # TODO. This is probably a poor way of detecting printable characters.  However, it shouldn't incorrectly -  # identify any of chrome's own keyboard shortcuts as printable. -  isPrintable: (event) -> -    return false if event.metaKey or event.ctrlKey or event.altKey -    keyChar = -      if event.type == "keypress" -        String.fromCharCode event.charCode -      else -        @getKeyChar event -    keyChar.length == 1 +      # <c-[> is mapped to Escape in Vim by default. +      event.key == "Escape" or (useVimLikeEscape and @getKeyCharString(event) == "<c-[>") -  # Return the Vimium key representation for this keyboard event. Return a falsy value (the empty string or -  # undefined) when no Vimium representation is appropriate. -  getKeyCharString: (event, allKeydownEvents = false) -> -    switch event.type -      when "keypress" -        # Ignore modifier keys by themselves. -        if 31 < event.keyCode -          String.fromCharCode event.charCode +  isBackspace: (event) -> +    event.key in ["Backspace", "Delete"] -      when "keydown" -        # Handle special keys and normal input keys with modifiers being pressed. -        keyChar = @getKeyChar event -        if 1 < keyChar.length or (keyChar.length == 1 and (event.metaKey or event.ctrlKey or event.altKey)) or allKeydownEvents -          modifiers = [] - -          keyChar = keyChar.toUpperCase() if event.shiftKey -          # These must be in alphabetical order (to match the sorted modifier order in Commands.normalizeKey). -          modifiers.push "a" if event.altKey -          modifiers.push "c" if event.ctrlKey -          modifiers.push "m" if event.metaKey - -          keyChar = [modifiers..., keyChar].join "-" -          if 1 < keyChar.length then "<#{keyChar}>" else keyChar +  isPrintable: (event) -> +    @getKeyCharString(event)?.length == 1 + +  isModifier: (event) -> +    event.key in ["Control", "Shift", "Alt", "OS", "AltGraph", "Meta"] + +  enUsTranslations: +    "Backquote":     ["`", "~"] +    "Minus":         ["-", "_"] +    "Equal":         ["=", "+"] +    "Backslash":     ["\\","|"] +    "IntlBackslash": ["\\","|"] +    "BracketLeft":   ["[", "{"] +    "BracketRight":  ["]", "}"] +    "Semicolon":     [";", ":"] +    "Quote":         ["'", '"'] +    "Comma":         [",", "<"] +    "Period":        [".", ">"] +    "Slash":         ["/", "?"] +    "Space":         [" ", " "] +    "Digit1":        ["1", "!"] +    "Digit2":        ["2", "@"] +    "Digit3":        ["3", "#"] +    "Digit4":        ["4", "$"] +    "Digit5":        ["5", "%"] +    "Digit6":        ["6", "^"] +    "Digit7":        ["7", "&"] +    "Digit8":        ["8", "*"] +    "Digit9":        ["9", "("] +    "Digit0":        ["0", ")"]  KeyboardUtils.init() -root = exports ? window +root = exports ? (window.root ?= {})  root.KeyboardUtils = KeyboardUtils -# TODO(philc): A lot of code uses this keyCodes hash... maybe we shouldn't export it as a global. -root.keyCodes = KeyboardUtils.keyCodes +extend window, root unless exports? diff --git a/lib/rect.coffee b/lib/rect.coffee index d4807cc2..0e9c3417 100644 --- a/lib/rect.coffee +++ b/lib/rect.coffee @@ -67,12 +67,18 @@ Rect =      rects.filter (rect) -> rect.height > 0 and rect.width > 0 -  contains: (rect1, rect2) -> +  # Determine whether two rects overlap. +  intersects: (rect1, rect2) ->      rect1.right > rect2.left and      rect1.left < rect2.right and      rect1.bottom > rect2.top and      rect1.top < rect2.bottom +  # Determine whether two rects overlap, including 0-width intersections at borders. +  intersectsStrict: (rect1, rect2) -> +    rect1.right >= rect2.left and rect1.left <= rect2.right and +    rect1.bottom >= rect2.top and rect1.top <= rect2.bottom +    equals: (rect1, rect2) ->      for property in ["top", "bottom", "left", "right", "width", "height"]        return false if rect1[property] != rect2[property] @@ -82,14 +88,6 @@ Rect =      @create (Math.max rect1.left, rect2.left), (Math.max rect1.top, rect2.top),          (Math.min rect1.right, rect2.right), (Math.min rect1.bottom, rect2.bottom) -  # Determine whether two rects overlap. -  rectsOverlap: do -> -    halfOverlapChecker = (rect1, rect2) -> -      (rect1.left <= rect2.left <= rect1.right or rect1.left <= rect2.right <= rect1.right) and -        (rect1.top <= rect2.top <= rect1.bottom or rect1.top <= rect2.bottom <= rect1.bottom) - -    (rect1, rect2) -> -      halfOverlapChecker(rect1, rect2) or halfOverlapChecker rect2, rect1 - -root = exports ? window +root = exports ? (window.root ?= {})  root.Rect = Rect +extend window, root unless exports? diff --git a/lib/settings.coffee b/lib/settings.coffee index e16261d0..fd1ef268 100644 --- a/lib/settings.coffee +++ b/lib/settings.coffee @@ -10,9 +10,14 @@  #  # In all cases except Settings.defaults, values are stored as jsonified strings. +# If the current frame is the Vomnibar or the HUD, then we'll need our Chrome stubs for the tests. +window.chrome ?= window.top?.chrome + +storageArea = if chrome.storage.sync? then "sync" else "local" +  Settings =    debug: false -  storage: chrome.storage.sync +  storage: chrome.storage[storageArea]    cache: {}    isLoaded: false    onLoadedCallbacks: [] @@ -25,16 +30,26 @@ Settings =        @cache = if Utils.isBackgroundPage() then localStorage else extend {}, localStorage        @runOnLoadedCallbacks() -    chrome.storage.local.get null, (localItems) => -      localItems = {} if chrome.runtime.lastError -      @storage.get null, (syncedItems) => -        unless chrome.runtime.lastError -          @handleUpdateFromChromeStorage key, value for own key, value of extend localItems, syncedItems +    # Test chrome.storage.sync to see if it is enabled. +    # NOTE(mrmr1993, 2017-04-18): currently the API is defined in FF, but it is disabled behind a flag in +    # about:config. Every use sets chrome.runtime.lastError, so we use that to check whether we can use it. +    chrome.storage.sync.get null, => +      if chrome.runtime.lastError +        storageArea = "local" +        @storage = chrome.storage[storageArea] + +      # Delay this initialisation until after the correct storage area is known.  The significance of this is +      # that it delays the on-loaded callbacks. +      chrome.storage.local.get null, (localItems) => +        localItems = {} if chrome.runtime.lastError +        @storage.get null, (syncedItems) => +          unless chrome.runtime.lastError +            @handleUpdateFromChromeStorage key, value for own key, value of extend localItems, syncedItems -        chrome.storage.onChanged.addListener (changes, area) => -          @propagateChangesFromChromeStorage changes if area == "sync" +          chrome.storage.onChanged.addListener (changes, area) => +            @propagateChangesFromChromeStorage changes if area == storageArea -        @runOnLoadedCallbacks() +          @runOnLoadedCallbacks()    # Called after @cache has been initialized.  On extension pages, this will be called twice, but that does    # not matter because it's idempotent. @@ -71,12 +86,15 @@ Settings =      if @shouldSyncKey key        if shouldSetInSyncedStorage          setting = {}; setting[key] = @cache[key] -        @log "   chrome.storage.sync.set(#{key})" +        @log "   chrome.storage.#{storageArea}.set(#{key})"          @storage.set setting -      if Utils.isBackgroundPage() +      if Utils.isBackgroundPage() and storageArea == "sync"          # Remove options installed by the "copyNonDefaultsToChromeStorage-20150717" migration; see below.          @log "   chrome.storage.local.remove(#{key})"          chrome.storage.local.remove key +    # NOTE(mrmr1993): In FF, |value| will be garbage collected when the page owning it is unloaded. +    # Any postUpdateHooks that can be called from the options page/exclusions popup should be careful not to +    # use |value| asynchronously, or else it may refer to a |DeadObject| and accesses will throw an error.      @performPostUpdateHook key, value    clear: (key) -> @@ -98,7 +116,7 @@ Settings =    nuke: (key) ->      delete localStorage[key]      chrome.storage.local.remove key -    chrome.storage.sync.remove key +    chrome.storage.sync?.remove key    # For development only.    log: (args...) -> @@ -153,23 +171,23 @@ Settings =      # put in an example search engine      searchEngines:        """ -      w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s Wikipedia +      w: https://www.wikipedia.org/w/index.php?title=Special:Search&search=%s Wikipedia        # More examples.        #        # (Vimium supports search completion Wikipedia, as        # above, and for these.)        # -      # g: http://www.google.com/search?q=%s Google -      # l: http://www.google.com/search?q=%s&btnI I'm feeling lucky... -      # y: http://www.youtube.com/results?search_query=%s Youtube +      # g: https://www.google.com/search?q=%s Google +      # l: https://www.google.com/search?q=%s&btnI I'm feeling lucky... +      # y: https://www.youtube.com/results?search_query=%s Youtube        # gm: https://www.google.com/maps?q=%s Google maps        # b: https://www.bing.com/search?q=%s Bing        # d: https://duckduckgo.com/?q=%s DuckDuckGo -      # az: http://www.amazon.com/s/?field-keywords=%s Amazon +      # az: https://www.amazon.com/s/?field-keywords=%s Amazon        # qw: https://www.qwant.com/?q=%s Qwant        """ -    newTabUrl: "chrome://newtab" +    newTabUrl: "about:newtab"      grabBackFocus: false      regexFindMode: false      waitForEnterForFilteredHints: false # Note: this defaults to true for new users; see below. @@ -178,26 +196,30 @@ Settings =      helpDialog_showAdvancedCommands: false      optionsPage_showAdvancedOptions: false      passNextKeyKeys: [] +    ignoreKeyboardLayout: false  Settings.init()  # Perform migration from old settings versions, if this is the background page.  if Utils.isBackgroundPage() +  Settings.applyMigrations = -> +    unless Settings.get "settingsVersion" +      # This is a new install.  For some settings, we retain a legacy default behaviour for existing users but +      # use a non-default behaviour for new users. -  unless Settings.get "settingsVersion" -    # This is a new install.  For some settings, we retain a legacy default behaviour for existing users but -    # use a non-default behaviour for new users. +      # For waitForEnterForFilteredHints, "true" gives a better UX; see #1950.  However, forcing the change on +      # existing users would be unnecessarily disruptive.  So, only new users default to "true". +      Settings.set "waitForEnterForFilteredHints", true -    # For waitForEnterForFilteredHints, "true" gives a better UX; see #1950.  However, forcing the change on -    # existing users would be unnecessarily disruptive.  So, only new users default to "true". -    Settings.set "waitForEnterForFilteredHints", true +    # We use settingsVersion to coordinate any necessary schema changes. +    Settings.set("settingsVersion", Utils.getCurrentVersion()) -  # We use settingsVersion to coordinate any necessary schema changes. -  Settings.set("settingsVersion", Utils.getCurrentVersion()) +    # Remove legacy key which was used to control storage migration.  This was after 1.57 (2016-10-01), and can +    # be removed after 1.58 has been out for sufficiently long. +    Settings.nuke "copyNonDefaultsToChromeStorage-20150717" -  # Remove legacy key which was used to control storage migration.  This was after 1.57 (2016-10-01), and can -  # be removed after 1.58 has been out for sufficiently long. -  Settings.nuke "copyNonDefaultsToChromeStorage-20150717" +  Settings.onLoaded Settings.applyMigrations.bind Settings -root = exports ? window +root = exports ? (window.root ?= {})  root.Settings = Settings +extend window, root unless exports? diff --git a/lib/utils.coffee b/lib/utils.coffee index babb5f96..6f38be8f 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -1,4 +1,27 @@ +# Only pass events to the handler if they are marked as trusted by the browser. +# This is kept in the global namespace for brevity and ease of use. +window.forTrusted ?= (handler) -> (event) -> +  if event?.isTrusted +    handler.apply this, arguments +  else +    true + +browserInfo = browser?.runtime?.getBrowserInfo?() +  Utils = +  isFirefox: do -> +    # NOTE(mrmr1993): This test only works in the background page, this is overwritten by isEnabledForUrl for +    # content scripts. +    isFirefox = false +    browserInfo?.then? (browserInfo) -> +      isFirefox = browserInfo?.name == "Firefox" +    -> isFirefox +  firefoxVersion: do -> +    # NOTE(mrmr1993): This only works in the background page. +    ffVersion = undefined +    browserInfo?.then? (browserInfo) -> +      ffVersion = browserInfo?.version +    -> ffVersion    getCurrentVersion: ->      chrome.runtime.getManifest().version @@ -9,14 +32,6 @@ Utils =    # Returns true whenever the current page is the extension's background page.    isBackgroundPage: -> @isExtensionPage() and chrome.extension.getBackgroundPage?() == window -  # Takes a dot-notation object string and calls the function that it points to with the correct value for -  # 'this'. -  invokeCommandString: (str, args...) -> -    [names..., name] = str.split '.' -    obj = window -    obj = obj[component] for component in names -    obj[name].apply obj, args -    # Escape all special characters, so RegExp will parse the string 'as is'.    # Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex    escapeRegexSpecialCharacters: do -> @@ -41,7 +56,7 @@ Utils =      url.startsWith "javascript:"    hasFullUrlPrefix: do -> -    urlPrefix = new RegExp "^[a-z]{3,}://." +    urlPrefix = new RegExp "^[a-z][-+.a-z0-9]{2,}://."      (url) -> urlPrefix.test url    # Decode valid escape sequences in a URI.  This is intended to mimic the best-effort decoding @@ -312,8 +327,11 @@ class JobRunner    onReady: (callback) ->      @fetcher.use callback -root = exports ? window +root = exports ? (window.root ?= {})  root.Utils = Utils  root.SimpleCache = SimpleCache  root.AsyncDataFetcher = AsyncDataFetcher  root.JobRunner = JobRunner +unless exports? +  root.extend = extend +  extend window, root diff --git a/manifest.json b/manifest.json index a40cd134..62a19b35 100644 --- a/manifest.json +++ b/manifest.json @@ -1,18 +1,18 @@  {    "manifest_version": 2,    "name": "Vimium", -  "version": "1.57", +  "version": "1.62",    "description": "The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.",    "icons": {  "16": "icons/icon16.png",                "48": "icons/icon48.png",               "128": "icons/icon128.png" }, +  "minimum_chrome_version": "51.0",    "background": {      "scripts": [        "lib/utils.js",        "lib/settings.js",        "background_scripts/bg_utils.js",        "background_scripts/commands.js", -      "lib/clipboard.js",        "background_scripts/exclusions.js",        "background_scripts/completion_engines.js",        "background_scripts/completion_search.js", @@ -21,12 +21,17 @@        "background_scripts/main.js"      ]    }, -  "options_page": "pages/options.html", +  "options_ui": { +    "page": "pages/options.html", +    "chrome_style": false, +    "open_in_tab": true +  },    "permissions": [      "tabs",      "bookmarks",      "history",      "clipboardRead", +    "clipboardWrite",      "storage",      "sessions",      "notifications", @@ -36,7 +41,7 @@    "content_scripts": [      {        "_comment": -        "IMPORTANT: All resources listed here must also be listed in ./pages/vimium_resources.html.", +        "IMPORTANT: All resources listed here must also be listed with the others -- in order -- in ./pages/*.html",        "matches": ["<all_urls>"],        "js": ["lib/utils.js",               "lib/keyboard_utils.js", @@ -56,11 +61,13 @@               "content_scripts/mode_key_handler.js",               "content_scripts/mode_visual.js",               "content_scripts/hud.js", +             "content_scripts/mode_normal.js",               "content_scripts/vimium_frontend.js"              ],        "css": ["content_scripts/vimium.css"],        "run_at": "document_start", -      "all_frames": true +      "all_frames": true, +      "match_about_blank": true      },      {        "matches": ["file:///", "file:///*/"], diff --git a/pages/blank.html b/pages/blank.html index c238282d..d026912e 100644 --- a/pages/blank.html +++ b/pages/blank.html @@ -1,7 +1,28 @@  <html>    <head>      <title>New Tab</title> -    <link rel="import" href="vimium_resources.html"> +    <script src="../lib/utils.js"></script> +    <script src="../lib/keyboard_utils.js"></script> +    <script src="../lib/dom_utils.js"></script> +    <script src="../lib/rect.js"></script> +    <script src="../lib/handler_stack.js"></script> +    <script src="../lib/settings.js"></script> +    <script src="../lib/find_mode_history.js"></script> +    <script src="../content_scripts/mode.js"></script> +    <script src="../content_scripts/ui_component.js"></script> +    <script src="../content_scripts/link_hints.js"></script> +    <script src="../content_scripts/vomnibar.js"></script> +    <script src="../content_scripts/scroller.js"></script> +    <script src="../content_scripts/marks.js"></script> +    <script src="../content_scripts/mode_insert.js"></script> +    <script src="../content_scripts/mode_find.js"></script> +    <script src="../content_scripts/mode_key_handler.js"></script> +    <script src="../content_scripts/mode_visual.js"></script> +    <script src="../content_scripts/hud.js"></script> +    <script src="../content_scripts/mode_normal.js"></script> +    <script src="../content_scripts/vimium_frontend.js"></script> +    <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> +    </head>    <body>    </body> diff --git a/pages/completion_engines.html b/pages/completion_engines.html index 0c350664..3313b26c 100644 --- a/pages/completion_engines.html +++ b/pages/completion_engines.html @@ -4,7 +4,28 @@      <!-- We re-use some styling from the options page, so that the look and feel here is similar -->      <link rel="stylesheet" type="text/css" href="options.css">      <link rel="stylesheet" type="text/css" href="completion_engines.css"> -    <link rel="import" href="vimium_resources.html"> +    <script src="../lib/utils.js"></script> +    <script src="../lib/keyboard_utils.js"></script> +    <script src="../lib/dom_utils.js"></script> +    <script src="../lib/rect.js"></script> +    <script src="../lib/handler_stack.js"></script> +    <script src="../lib/settings.js"></script> +    <script src="../lib/find_mode_history.js"></script> +    <script src="../content_scripts/mode.js"></script> +    <script src="../content_scripts/ui_component.js"></script> +    <script src="../content_scripts/link_hints.js"></script> +    <script src="../content_scripts/vomnibar.js"></script> +    <script src="../content_scripts/scroller.js"></script> +    <script src="../content_scripts/marks.js"></script> +    <script src="../content_scripts/mode_insert.js"></script> +    <script src="../content_scripts/mode_find.js"></script> +    <script src="../content_scripts/mode_key_handler.js"></script> +    <script src="../content_scripts/mode_visual.js"></script> +    <script src="../content_scripts/hud.js"></script> +    <script src="../content_scripts/mode_normal.js"></script> +    <script src="../content_scripts/vimium_frontend.js"></script> +    <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> +      <script src="../background_scripts/completion_engines.js"></script>      <script src="completion_engines.js"></script>    </head> diff --git a/pages/help_dialog.coffee b/pages/help_dialog.coffee index 4ac9116b..08180a72 100644 --- a/pages/help_dialog.coffee +++ b/pages/help_dialog.coffee @@ -73,7 +73,7 @@ HelpDialog =            for key in command.keys.sort compareKeys              @instantiateHtmlTemplate keysElement, "#keysTemplate", (element) ->                lastElement = element -              $$(element, ".vimiumHelpDialogKey").innerHTML = Utils.escapeHtml key +              $$(element, ".vimiumHelpDialogKey").textContent = key            # And strip off the trailing ", ", if necessary.            lastElement.removeChild $$ lastElement, ".commaSeparator" if lastElement @@ -83,7 +83,7 @@ HelpDialog =                commandNameElement.textContent = command.command                commandNameElement.title = "Click to copy \"#{command.command}\" to clipboard."                commandNameElement.addEventListener "click", -> -                chrome.runtime.sendMessage handler: "copyToClipboard", data: commandNameElement.textContent +                HUD.copyToClipboard commandNameElement.textContent                  HUD.showForDuration("Yanked #{commandNameElement.textContent}.", 2000)        @showAdvancedCommands(@getShowAdvancedCommands()) @@ -109,7 +109,7 @@ HelpDialog =      vimiumHelpDialogContainer.scrollTop += scrollHeightDelta if 0 < scrollHeightDelta    showAdvancedCommands: (visible) -> -    document.getElementById("toggleAdvancedCommands").innerHTML = +    document.getElementById("toggleAdvancedCommands").textContent =        if visible then "Hide advanced commands" else "Show advanced commands"      # Add/remove the showAdvanced class to show/hide advanced commands. @@ -131,6 +131,9 @@ UIComponentServer.registerHandler (event) ->        # Abandon any HUD which might be showing within the help dialog.        HUD.abandon() +document.addEventListener "DOMContentLoaded", -> +  DomUtils.injectUserCss() # Manually inject custom user styles. +  root = exports ? window  root.HelpDialog = HelpDialog  root.isVimiumHelpDialog = true diff --git a/pages/help_dialog.html b/pages/help_dialog.html index 7bc0d86c..7f053265 100644 --- a/pages/help_dialog.html +++ b/pages/help_dialog.html @@ -1,7 +1,28 @@  <html>    <head>      <title>Vimium Help</title> -    <link rel="import" href="vimium_resources.html"> +    <script src="../lib/utils.js"></script> +    <script src="../lib/keyboard_utils.js"></script> +    <script src="../lib/dom_utils.js"></script> +    <script src="../lib/rect.js"></script> +    <script src="../lib/handler_stack.js"></script> +    <script src="../lib/settings.js"></script> +    <script src="../lib/find_mode_history.js"></script> +    <script src="../content_scripts/mode.js"></script> +    <script src="../content_scripts/ui_component.js"></script> +    <script src="../content_scripts/link_hints.js"></script> +    <script src="../content_scripts/vomnibar.js"></script> +    <script src="../content_scripts/scroller.js"></script> +    <script src="../content_scripts/marks.js"></script> +    <script src="../content_scripts/mode_insert.js"></script> +    <script src="../content_scripts/mode_find.js"></script> +    <script src="../content_scripts/mode_key_handler.js"></script> +    <script src="../content_scripts/mode_visual.js"></script> +    <script src="../content_scripts/hud.js"></script> +    <script src="../content_scripts/mode_normal.js"></script> +    <script src="../content_scripts/vimium_frontend.js"></script> +    <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> +      <script type="text/javascript" src="ui_component_server.js"></script>      <script type="text/javascript" src="help_dialog.js"></script>    </head> @@ -87,7 +108,7 @@              <a class="vimiumHelDialogLink" target="_blank"                href="https://chrome.google.com/webstore/detail/vimium/dbepggeogbaibhgnhhndojpepiihcmeb/reviews">Leave us                    feedback</a>.<br/> -            Found a bug? <a class="vimiumHelDialogLink" target="_blank" href="http://github.com/philc/vimium/issues">Report it here</a>. +            Found a bug? <a class="vimiumHelDialogLink" target="_blank" href="https://github.com/philc/vimium/issues">Report it here</a>.            </div>            <div class="vimiumReset vimiumColumn" style="text-align:right">              <span class="vimiumReset">Version <span id="help-dialog-version"></span></span><br/> diff --git a/pages/hud.coffee b/pages/hud.coffee index 36e4cbd2..5ff2e07e 100644 --- a/pages/hud.coffee +++ b/pages/hud.coffee @@ -12,25 +12,28 @@ setTextInInputElement = (inputElement, text) ->    selection.removeAllRanges()    selection.addRange range +document.addEventListener "DOMContentLoaded", -> +  DomUtils.injectUserCss() # Manually inject custom user styles. +  document.addEventListener "keydown", (event) ->    inputElement = document.getElementById "hud-find-input"    return unless inputElement? # Don't do anything if we're not in find mode. -  if (event.keyCode in [keyCodes.backspace, keyCodes.deleteKey] and inputElement.textContent.length == 0) or -     event.keyCode == keyCodes.enter or KeyboardUtils.isEscape event +  if (KeyboardUtils.isBackspace(event) and inputElement.textContent.length == 0) or +     event.key == "Enter" or KeyboardUtils.isEscape event      UIComponentServer.postMessage        name: "hideFindMode" -      exitEventIsEnter: event.keyCode == keyCodes.enter +      exitEventIsEnter: event.key == "Enter"        exitEventIsEscape: KeyboardUtils.isEscape event -  else if event.keyCode == keyCodes.upArrow +  else if event.key == "ArrowUp"      if rawQuery = FindModeHistory.getQuery findMode.historyIndex + 1        findMode.historyIndex += 1        findMode.partialQuery = findMode.rawQuery if findMode.historyIndex == 0        setTextInInputElement inputElement, rawQuery        findMode.executeQuery() -  else if event.keyCode == keyCodes.downArrow +  else if event.key == "ArrowDown"      findMode.historyIndex = Math.max -1, findMode.historyIndex - 1      rawQuery = if 0 <= findMode.historyIndex then FindModeHistory.getQuery findMode.historyIndex else findMode.partialQuery      setTextInInputElement inputElement, rawQuery @@ -58,7 +61,10 @@ handlers =      hud.innerText = "/\u200A" # \u200A is a "hair space", to leave enough space before the caret/first char.      inputElement = document.createElement "span" -    inputElement.contentEditable = "plaintext-only" +    try # NOTE(mrmr1993): Chrome supports non-standard "plaintext-only", which is what we *really* want. +      inputElement.contentEditable = "plaintext-only" +    catch # Fallback to standard-compliant version. +      inputElement.contentEditable = "true"      inputElement.id = "hud-find-input"      hud.appendChild inputElement @@ -89,5 +95,19 @@ handlers =        " (No matches)"      countElement.textContent = if showMatchText then countText else "" +  copyToClipboard: (data) -> +    focusedElement = document.activeElement +    Clipboard.copy data +    focusedElement?.focus() +    window.parent.focus() +    UIComponentServer.postMessage {name: "unfocusIfFocused"} + +  pasteFromClipboard: -> +    focusedElement = document.activeElement +    data = Clipboard.paste() +    focusedElement?.focus() +    window.parent.focus() +    UIComponentServer.postMessage {name: "pasteResponse", data} +  UIComponentServer.registerHandler ({data}) -> handlers[data.name ? data]? data  FindModeHistory.init() diff --git a/pages/hud.html b/pages/hud.html index 60d737e1..7bd27171 100644 --- a/pages/hud.html +++ b/pages/hud.html @@ -2,9 +2,12 @@    <head>      <title>HUD</title>      <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> +    <script type="text/javascript" src="../lib/utils.js"></script>      <script type="text/javascript" src="../lib/dom_utils.js"></script> +    <script type="text/javascript" src="../lib/settings.js"></script>      <script type="text/javascript" src="../lib/keyboard_utils.js"></script>      <script type="text/javascript" src="../lib/find_mode_history.js"></script> +    <script type="text/javascript" src="../lib/clipboard.js"></script>      <script type="text/javascript" src="ui_component_server.js"></script>      <script type="text/javascript" src="hud.js"></script>    </head> diff --git a/pages/logging.coffee b/pages/logging.coffee index 3ccef4ff..a437b442 100644 --- a/pages/logging.coffee +++ b/pages/logging.coffee @@ -1,6 +1,7 @@  $ = (id) -> document.getElementById id  document.addEventListener "DOMContentLoaded", -> +  DomUtils.injectUserCss() # Manually inject custom user styles.    $("vimiumVersion").innerText = Utils.getCurrentVersion()    chrome.storage.local.get "installDate", (items) -> diff --git a/pages/logging.html b/pages/logging.html index bc4ffb80..17aafd70 100644 --- a/pages/logging.html +++ b/pages/logging.html @@ -1,7 +1,28 @@  <html>    <head>      <title>Vimium Logging</title> -    <link rel="import" href="vimium_resources.html"> +    <script src="../lib/utils.js"></script> +    <script src="../lib/keyboard_utils.js"></script> +    <script src="../lib/dom_utils.js"></script> +    <script src="../lib/rect.js"></script> +    <script src="../lib/handler_stack.js"></script> +    <script src="../lib/settings.js"></script> +    <script src="../lib/find_mode_history.js"></script> +    <script src="../content_scripts/mode.js"></script> +    <script src="../content_scripts/ui_component.js"></script> +    <script src="../content_scripts/link_hints.js"></script> +    <script src="../content_scripts/vomnibar.js"></script> +    <script src="../content_scripts/scroller.js"></script> +    <script src="../content_scripts/marks.js"></script> +    <script src="../content_scripts/mode_insert.js"></script> +    <script src="../content_scripts/mode_find.js"></script> +    <script src="../content_scripts/mode_key_handler.js"></script> +    <script src="../content_scripts/mode_visual.js"></script> +    <script src="../content_scripts/hud.js"></script> +    <script src="../content_scripts/mode_normal.js"></script> +    <script src="../content_scripts/vimium_frontend.js"></script> +    <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> +      <script src="logging.js"></script>      <style type="text/css">        body { diff --git a/pages/options.coffee b/pages/options.coffee index 7021322a..9e95bcd3 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -39,9 +39,14 @@ class Option      bgSettings.clear @field      @fetch() +  @onSaveCallbacks: [] +  @onSave: (callback) -> +    @onSaveCallbacks.push callback +    # Static method.    @saveOptions: ->      Option.all.map (option) -> option.save() +    callback() for callback in @onSaveCallbacks    # Abstract method; only implemented in sub-classes.    # Populate the option's DOM element (@element) with the setting's current value. @@ -93,8 +98,10 @@ class ExclusionRulesOption extends Option        element    populateElement: (rules) -> -    for rule in rules -      @appendRule rule +    # For the case of restoring a backup, we first have to remove existing rules. +    exclusionRules = $ "exclusionRules" +    exclusionRules.deleteRow 1 while exclusionRules.rows[1] +    @appendRule rule for rule in rules    # Append a row for a new rule.  Return the newly-added element.    appendRule: (rule) -> @@ -193,6 +200,7 @@ Options =    nextPatterns: NonEmptyTextOption    previousPatterns: NonEmptyTextOption    regexFindMode: CheckBoxOption +  ignoreKeyboardLayout: CheckBoxOption    scrollStepSize: NumberOption    smoothScroll: CheckBoxOption    grabBackFocus: CheckBoxOption @@ -203,7 +211,7 @@ Options =  initOptionsPage = ->    onUpdated = ->      $("saveOptions").removeAttribute "disabled" -    $("saveOptions").innerHTML = "Save Changes" +    $("saveOptions").textContent = "Save Changes"    # Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints".    maintainLinkHintsView = -> @@ -221,10 +229,10 @@ initOptionsPage = ->    maintainAdvancedOptions = ->      if bgSettings.get "optionsPage_showAdvancedOptions"        $("advancedOptions").style.display = "table-row-group" -      $("advancedOptionsButton").innerHTML = "Hide Advanced Options" +      $("advancedOptionsButton").textContent = "Hide Advanced Options"      else        $("advancedOptions").style.display = "none" -      $("advancedOptionsButton").innerHTML = "Show Advanced Options" +      $("advancedOptionsButton").textContent = "Show Advanced Options"    maintainAdvancedOptions()    toggleAdvancedOptions = (event) -> @@ -237,9 +245,10 @@ initOptionsPage = ->      HelpDialog.toggle showAllCommandDetails: true    saveOptions = -> +    $("linkHintCharacters").value = $("linkHintCharacters").value.toLowerCase()      Option.saveOptions()      $("saveOptions").disabled = true -    $("saveOptions").innerHTML = "No Changes" +    $("saveOptions").textContent = "Saved"    $("saveOptions").addEventListener "click", saveOptions    $("advancedOptionsButton").addEventListener "click", toggleAdvancedOptions @@ -248,7 +257,7 @@ initOptionsPage = ->    for element in document.getElementsByClassName "nonEmptyTextOption"      element.className = element.className + " example info" -    element.innerHTML = "Leave empty to reset this option." +    element.textContent = "Leave empty to reset this option."    window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled @@ -264,10 +273,30 @@ initOptionsPage = ->    maintainLinkHintsView()  initPopupPage = -> -  chrome.tabs.getSelected null, (tab) -> +  chrome.tabs.query { active: true, currentWindow: true }, ([tab]) ->      exclusions = null      document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html") +    tabPorts = chrome.extension.getBackgroundPage().portsForTab[tab.id] +    unless tabPorts and Object.keys(tabPorts).length > 0 +      # The browser has disabled Vimium on this page. Place a message explaining this into the popup. +      document.body.innerHTML = """ +        <div style="width: 400px; margin: 5px;"> +          <p style="margin-bottom: 5px;"> +            Vimium is not running on this page. +          </p> +          <p style="margin-bottom: 5px;"> +            Your browser does not run web extensions like Vimium on certain pages, +            usually for security reasons. +          </p> +          <p> +            Unless your browser's developers change their policy, then unfortunately it is not possible to make Vimium (or any other +            web extension, for that matter) work on this page. +          </p> +        </div> +      """ +      return +      # As the active URL, we choose the most recently registered URL from a frame in the tab, or the tab's own      # URL.      url = chrome.extension.getBackgroundPage().urlForTab[tab.id] || tab.url @@ -285,12 +314,12 @@ initPopupPage = ->      onUpdated = ->        $("helpText").innerHTML = "Type <strong>Ctrl-Enter</strong> to save and close."        $("saveOptions").removeAttribute "disabled" -      $("saveOptions").innerHTML = "Save Changes" +      $("saveOptions").textContent = "Save Changes"        updateState() if exclusions      saveOptions = ->        Option.saveOptions() -      $("saveOptions").innerHTML = "Saved" +      $("saveOptions").textContent = "Saved"        $("saveOptions").disabled = true      $("saveOptions").addEventListener "click", saveOptions @@ -309,6 +338,7 @@ initPopupPage = ->  #  # Initialization.  document.addEventListener "DOMContentLoaded", -> +  DomUtils.injectUserCss() # Manually inject custom user styles.    xhr = new XMLHttpRequest()    xhr.open 'GET', chrome.extension.getURL('pages/exclusions.html'), true    xhr.onreadystatechange = -> @@ -320,6 +350,55 @@ document.addEventListener "DOMContentLoaded", ->    xhr.send() +# +# Backup and restore. "?" is for the tests." +DomUtils?.documentReady -> +  # Only initialize backup/restore on the options page (not the popup). +  return unless location.pathname == "/pages/options.html" + +  restoreSettingsVersion = null + +  populateBackupLinkUrl = -> +    backup = settingsVersion: bgSettings.get "settingsVersion" +    for option in Option.all +      backup[option.field] = option.readValueFromElement() +    # Create the blob in the background page so it isn't garbage collected when the page closes in FF. +    bgWin = chrome.extension.getBackgroundPage() +    blob = new bgWin.Blob [ JSON.stringify backup, null, 2 ] +    $("backupLink").href = bgWin.URL.createObjectURL blob + +  $("backupLink").addEventListener "mousedown", populateBackupLinkUrl, true + +  $("chooseFile").addEventListener "change", (event) -> +    document.activeElement?.blur() +    files = event.target.files +    if files.length == 1 +      file = files[0] +      reader = new FileReader +      reader.readAsText file +      reader.onload = (event) -> +        try +          backup = JSON.parse reader.result +        catch +          alert "Failed to parse Vimium backup." +          return + +        restoreSettingsVersion = backup["settingsVersion"] if "settingsVersion" of backup +        for option in Option.all +          if option.field of backup +            option.populateElement backup[option.field] +            option.onUpdated() + +  Option.onSave -> +    # If we're restoring a backup, then restore the backed up settingsVersion. +    if restoreSettingsVersion? +      bgSettings.set "settingsVersion", restoreSettingsVersion +      restoreSettingsVersion = null +    # Reset the restore-backup input. +    $("chooseFile").value = "" +    # We need to apply migrations in case we are restoring an old backup. +    bgSettings.applyMigrations() +  # Exported for tests.  root = exports ? window  extend root, {Options, isVimiumOptionsPage: true} diff --git a/pages/options.css b/pages/options.css index 490ae164..dab342a3 100644 --- a/pages/options.css +++ b/pages/options.css @@ -115,7 +115,7 @@ input#scrollStepSize {  textarea#userDefinedLinkHintCss, textarea#keyMappings, textarea#searchEngines {    width: 100%;;    min-height: 140px; -  white-space: nowrap; +  white-space: pre;  }  input#previousPatterns, input#nextPatterns {    width: 100%; @@ -227,7 +227,10 @@ input.pattern, input.passKeys, .exclusionHeaderText {  #saveOptionsTableData {    float: right;  } -#saveOptions { +#saveOptions, #exclusionAddButton {    white-space: nowrap;    width: 110px;  } +#backupLink { +  cursor: pointer; +} diff --git a/pages/options.html b/pages/options.html index 92bed6f0..b118bbd9 100644 --- a/pages/options.html +++ b/pages/options.html @@ -2,7 +2,28 @@    <head>      <title>Vimium Options</title>      <link rel="stylesheet" type="text/css" href="options.css"> -    <link rel="import" href="vimium_resources.html"> +    <script src="../lib/utils.js"></script> +    <script src="../lib/keyboard_utils.js"></script> +    <script src="../lib/dom_utils.js"></script> +    <script src="../lib/rect.js"></script> +    <script src="../lib/handler_stack.js"></script> +    <script src="../lib/settings.js"></script> +    <script src="../lib/find_mode_history.js"></script> +    <script src="../content_scripts/mode.js"></script> +    <script src="../content_scripts/ui_component.js"></script> +    <script src="../content_scripts/link_hints.js"></script> +    <script src="../content_scripts/vomnibar.js"></script> +    <script src="../content_scripts/scroller.js"></script> +    <script src="../content_scripts/marks.js"></script> +    <script src="../content_scripts/mode_insert.js"></script> +    <script src="../content_scripts/mode_find.js"></script> +    <script src="../content_scripts/mode_key_handler.js"></script> +    <script src="../content_scripts/mode_visual.js"></script> +    <script src="../content_scripts/hud.js"></script> +    <script src="../content_scripts/mode_normal.js"></script> +    <script src="../content_scripts/vimium_frontend.js"></script> +    <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> +      <script type="text/javascript" src="options.js"></script>    </head> @@ -46,7 +67,7 @@ unmapAll                  <a href="#" id="showCommands">Show available commands</a>.                </div>              </div> -            <textarea id="keyMappings" type="text"></textarea> +            <textarea id="keyMappings" type="text" tabIndex="1"></textarea>            </td>          </tr>          <tr> @@ -95,11 +116,11 @@ b: http://b.com/?q=%s description              </td>            </tr>            <tr id="linkHintNumbersContainer"> -            <td class="caption">Numbers used<br/> for link hints</td> +            <td class="caption">Characters used<br/> for link hints</td>              <td verticalAlign="top">                  <div class="help">                    <div class="example"> -                    The numbers placed next to each link after typing "f" to enter link-hint mode. +                    The characters placed next to each link after typing "f" to enter link-hint mode.                    </div>                  </div>                  <input id="linkHintNumbers" type="text" /> @@ -125,7 +146,7 @@ b: http://b.com/?q=%s description                </div>                <label>                  <input id="filterLinkHints" type="checkbox"/> -                Use the link's name and numbers for link-hint filtering +                Use the link's name and characters for link-hint filtering                </label>              </td>            </tr> @@ -182,7 +203,22 @@ b: http://b.com/?q=%s description                </div>                <label>                  <input id="regexFindMode" type="checkbox"/> -                Treat find queries as regular expressions +                Treat find queries as JavaScript regular expressions +              </label> +            </td> +          </tr> +          <tr> +            <td class="caption"></td> +            <td verticalAlign="top" class="booleanOption"> +              <div class="help"> +                <div class="example"> +                  This forces the use of <code>en-US</code> QWERTY layout and +                  can be helpful for non-Latin keyboards. +                </div> +              </div> +              <label> +                <input id="ignoreKeyboardLayout" type="checkbox"/> +                Ignore keyboard layout                </label>              </td>            </tr> @@ -228,7 +264,7 @@ b: http://b.com/?q=%s description              <td verticalAlign="top">                  <div class="help">                    <div class="example"> -                     The search engine to use in the Vomnibar <br> (e.g.: "http://duckduckgo.com/?q="). +                     The search engine to use in the Vomnibar <br> (e.g.: "https://duckduckgo.com/?q=").                    </div>                  </div>                  <input id="searchUrl" type="text" /> @@ -236,11 +272,12 @@ b: http://b.com/?q=%s description              </td>            </tr>            <tr> -            <td class="caption">CSS for link hints</td> +            <td class="caption">CSS for Vimium UI</td>              <td verticalAlign="top">                <div class="help">                  <div class="example"> -                  The CSS used to style the characters next to each link hint.<br/><br/> +                  These styles are applied to link hints, the Vomnibar, the help dialog, the exclusions pop-up and the HUD.<br /> +                  By default, this CSS is used to style the characters next to each link hint.<br/><br/>                    These styles are used in addition to and take precedence over Vimium's                    default styles.                  </div> @@ -280,6 +317,33 @@ b: http://b.com/?q=%s description            </tr>            -->          </tbody> +        <tbody id='backupAndRestore'> +          <tr> +            <td colspan="2"><header>Backup and Restore</header></td> +          </tr> +          <tr> +            <td class="caption">Backup</td> +            <td> +                <div class="help"> +                  <div class="example"> +                    Click to backup your settings, or right-click and select <i>Save As</i>. +                  </div> +                </div> +              <a id="backupLink" download="vimium-options.json">Click to download backup</a> +            </td> +          </tr> +          <tr> +            <td class="caption">Restore</td> +            <td> +                <div class="help"> +                  <div class="example"> +                    Choose a backup file to restore, then click <i>Save Changes</i>, below, to confirm. +                  </div> +                </div> +              <input id="chooseFile" type="file" accept=".json" style="width: 200px;"/> +            </td> +          </tr> +        </tbody>        </table>      </div> diff --git a/pages/popup.html b/pages/popup.html index fdf116e5..03c466d6 100644 --- a/pages/popup.html +++ b/pages/popup.html @@ -13,7 +13,6 @@        }        #helpText, #stateLine, #state { color: #979ca0; } -      #exclusionAddButton { width: 80px; }        #saveOptions {          margin-top: 5px; /* Match #exclusionAddButton */ @@ -49,6 +48,7 @@      </style>      <script src="../lib/utils.js"></script> +    <script src="../lib/dom_utils.js"></script>      <script src="../lib/settings.js"></script>      <script src="options.js"></script>    </head> diff --git a/pages/vimium_resources.html b/pages/vimium_resources.html deleted file mode 100644 index 2fad22a2..00000000 --- a/pages/vimium_resources.html +++ /dev/null @@ -1,24 +0,0 @@ -<!-- All content scripts (and CSS) listed in the manifest must be listed here too. -     These load Vimium on Vimium's internal pages (such as the options page).  --> - -<script src="/lib/utils.js"></script> -<script src="/lib/keyboard_utils.js"></script> -<script src="/lib/dom_utils.js"></script> -<script src="/lib/rect.js"></script> -<script src="/lib/handler_stack.js"></script> -<script src="/lib/settings.js"></script> -<script src="/lib/find_mode_history.js"></script> -<script src="/content_scripts/mode.js"></script> -<script src="/content_scripts/ui_component.js"></script> -<script src="/content_scripts/link_hints.js"></script> -<script src="/content_scripts/vomnibar.js"></script> -<script src="/content_scripts/scroller.js"></script> -<script src="/content_scripts/marks.js"></script> -<script src="/content_scripts/mode_insert.js"></script> -<script src="/content_scripts/mode_find.js"></script> -<script src="/content_scripts/mode_key_handler.js"></script> -<script src="/content_scripts/mode_visual.js"></script> -<script src="/content_scripts/hud.js"></script> -<script src="/content_scripts/vimium_frontend.js"></script> - -<link rel="stylesheet" type="text/css" href="/content_scripts/vimium.css" /> diff --git a/pages/vomnibar.coffee b/pages/vomnibar.coffee index 95ef8151..8c790ca8 100644 --- a/pages/vomnibar.coffee +++ b/pages/vomnibar.coffee @@ -106,17 +106,17 @@ class VomnibarUI      if (KeyboardUtils.isEscape(event))        return "dismiss"      else if (key == "up" || -        (event.shiftKey && event.keyCode == keyCodes.tab) || +        (event.shiftKey && event.key == "Tab") ||          (event.ctrlKey && (key == "k" || key == "p")))        return "up" -    else if (event.keyCode == keyCodes.tab && !event.shiftKey) +    else if (event.key == "Tab" && !event.shiftKey)        return "tab"      else if (key == "down" ||          (event.ctrlKey && (key == "j" || key == "n")))        return "down" -    else if (event.keyCode == keyCodes.enter) +    else if (event.key == "Enter")        return "enter" -    else if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey +    else if KeyboardUtils.isBackspace event        return "delete"      null @@ -125,8 +125,7 @@ class VomnibarUI      @lastAction = action = @actionFromKeyEvent event      return true unless action # pass through -    openInNewTab = @forceNewTab || -      (event.shiftKey || event.ctrlKey || event.altKey || KeyboardUtils.isPrimaryModifierKey(event)) +    openInNewTab = @forceNewTab || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey      if (action == "dismiss")        @hide()      else if action in [ "tab", "down" ] @@ -336,5 +335,8 @@ UIComponentServer.registerHandler (event) ->      when "hidden" then Vomnibar.onHidden()      when "activate" then Vomnibar.activate event.data +document.addEventListener "DOMContentLoaded", -> +  DomUtils.injectUserCss() # Manually inject custom user styles. +  root = exports ? window  root.Vomnibar = Vomnibar diff --git a/pages/vomnibar.html b/pages/vomnibar.html index 87acc081..19736d78 100644 --- a/pages/vomnibar.html +++ b/pages/vomnibar.html @@ -2,6 +2,7 @@    <head>      <title>Vomnibar</title>      <script type="text/javascript" src="../lib/utils.js"></script> +    <script type="text/javascript" src="../lib/settings.js"></script>      <script type="text/javascript" src="../lib/keyboard_utils.js"></script>      <script type="text/javascript" src="../lib/dom_utils.js"></script>      <script type="text/javascript" src="../lib/handler_stack.js"></script> diff --git a/tests/dom_tests/chrome.coffee b/tests/dom_tests/chrome.coffee index d4e6930d..1d04b654 100644 --- a/tests/dom_tests/chrome.coffee +++ b/tests/dom_tests/chrome.coffee @@ -7,6 +7,8 @@ root.chromeMessages = []  document.hasFocus = -> true +window.forTrusted = (handler) -> handler +  fakeManifest =    version: "1.51" diff --git a/tests/dom_tests/dom_tests.coffee b/tests/dom_tests/dom_tests.coffee index 9088fe30..6e422d46 100644 --- a/tests/dom_tests/dom_tests.coffee +++ b/tests/dom_tests/dom_tests.coffee @@ -1,33 +1,11 @@  window.vimiumDomTestsAreRunning = true  # Install frontend event handlers. -installListeners()  HUD.init()  Frame.registerFrameId chromeFrameId: 0 -installListener = (element, event, callback) -> -  element.addEventListener event, (-> callback.apply(this, arguments)), true -  getSelection = -> -    window.getSelection().toString() - -# A count of the number of keyboard events received by the page (for the most recently-sent keystroke).  E.g., -# we expect 3 if the keystroke is passed through (keydown, keypress, keyup), and 0 if it is suppressed. -pageKeyboardEventCount = 0 - -sendKeyboardEvent = (key) -> -  pageKeyboardEventCount = 0 -  response = window.callPhantom -    request: "keyboard" -    key: key - -sendKeyboardEvents = (keys) -> -  sendKeyboardEvent ch for ch in keys.split() - -# These listeners receive events after the main frontend listeners, and do not receive suppressed events. -for type in [ "keydown", "keypress", "keyup" ] -  installListener window, type, (event) -> -    pageKeyboardEventCount += 1 +  window.getSelection().toString()  commandName = commandCount = null @@ -98,7 +76,7 @@ createGeneralHintTests = (isFilteredMode) ->        assertStartPosition = (element1, element2) ->          assert.equal element1.getClientRects()[0].left, element2.getClientRects()[0].left          assert.equal element1.getClientRects()[0].top, element2.getClientRects()[0].top -      stub document.body, "style", "static" +      stub document.body.style, "position", "static"        linkHints = activateLinkHintsMode()        hintMarkers = getHintMarkers()        assertStartPosition document.getElementsByTagName("a")[0], hintMarkers[0] @@ -163,6 +141,16 @@ context "jsaction matching",        linkHints.deactivateMode()        assert.equal 0, hintMarkers.length +sendKeyboardEvent = (key, type="keydown", extra={}) -> +  handlerStack.bubbleEvent type, extend extra, +    type: type +    key: key +    preventDefault: -> +    stopImmediatePropagation: -> + +sendKeyboardEvents = (keys) -> +  sendKeyboardEvent key for key in keys.split "" +  inputs = []  context "Test link hints for focusing input elements correctly", @@ -227,16 +215,16 @@ context "Test link hints for changing mode",    should "change mode on shift", ->      assert.equal "curr-tab", @linkHints.mode.name -    sendKeyboardEvent "shift-down" +    sendKeyboardEvent "Shift", "keydown"      assert.equal "bg-tab", @linkHints.mode.name -    sendKeyboardEvent "shift-up" +    sendKeyboardEvent "Shift", "keyup"      assert.equal "curr-tab", @linkHints.mode.name    should "change mode on ctrl", ->      assert.equal "curr-tab", @linkHints.mode.name -    sendKeyboardEvent "ctrl-down" +    sendKeyboardEvent "Control", "keydown"      assert.equal "fg-tab", @linkHints.mode.name -    sendKeyboardEvent "ctrl-up" +    sendKeyboardEvent "Control", "keyup"      assert.equal "curr-tab", @linkHints.mode.name  context "Alphabetical link hints", @@ -247,6 +235,7 @@ context "Alphabetical link hints",      stubSettings "linkHintCharacters", "ab"      stub window, "windowIsFocused", -> true +    document.getElementById("test-div").innerHTML = ""      # Three hints will trigger double hint chars.      createLinks 3      @linkHints = activateLinkHintsMode() @@ -258,12 +247,13 @@ context "Alphabetical link hints",    should "label the hints correctly", ->      hintMarkers = getHintMarkers()      expectedHints = ["aa", "b", "ab"] +    assert.equal 3, hintMarkers.length      for hint, i in expectedHints        assert.equal hint, hintMarkers[i].hintString    should "narrow the hints", ->      hintMarkers = getHintMarkers() -    sendKeyboardEvent "A" +    sendKeyboardEvent "a"      assert.equal "none", hintMarkers[1].style.display      assert.equal "", hintMarkers[0].style.display @@ -314,24 +304,25 @@ context "Filtered link hints",      should "narrow the hints", ->        hintMarkers = getHintMarkers() -      sendKeyboardEvent "T" -      sendKeyboardEvent "R" +      sendKeyboardEvent "t" +      sendKeyboardEvent "r"        assert.equal "none", hintMarkers[0].style.display        assert.equal "3", hintMarkers[1].hintString        assert.equal "", hintMarkers[1].style.display -      sendKeyboardEvent "A" +      sendKeyboardEvent "a"        assert.equal "1", hintMarkers[3].hintString -    # This test is the same as above, but with an extra non-matching character. +    # This test is the same as above, but with an extra non-matching character.  The effect should be the +    # same.      should "narrow the hints and ignore typing mistakes", ->        hintMarkers = getHintMarkers() -      sendKeyboardEvent "T" -      sendKeyboardEvent "R" -      sendKeyboardEvent "X" +      sendKeyboardEvent "t" +      sendKeyboardEvent "r" +      sendKeyboardEvent "x"        assert.equal "none", hintMarkers[0].style.display        assert.equal "3", hintMarkers[1].hintString        assert.equal "", hintMarkers[1].style.display -      sendKeyboardEvent "A" +      sendKeyboardEvent "a"        assert.equal "1", hintMarkers[3].hintString    context "Image hints", @@ -428,9 +419,9 @@ context "Filtered link hints",      should "use tab to select the active hint", ->        sendKeyboardEvents "abc"        assert.equal "8", @getActiveHintMarker() -      sendKeyboardEvent "tab" +      sendKeyboardEvent "Tab", "keydown"        assert.equal "7", @getActiveHintMarker() -      sendKeyboardEvent "tab" +      sendKeyboardEvent "Tab", "keydown"        assert.equal "9", @getActiveHintMarker()  context "Input focus", @@ -445,29 +436,29 @@ context "Input focus",      document.getElementById("test-div").innerHTML = ""    should "focus the first element", -> -    focusInput 1 +    NormalModeCommands.focusInput 1      assert.equal "first", document.activeElement.id    should "focus the nth element", -> -    focusInput 100 +    NormalModeCommands.focusInput 100      assert.equal "third", document.activeElement.id    should "activate insert mode on the first element", -> -    focusInput 1 +    NormalModeCommands.focusInput 1      assert.isTrue InsertMode.permanentInstance.isActive()    should "activate insert mode on the first element", -> -    focusInput 100 +    NormalModeCommands.focusInput 100      assert.isTrue InsertMode.permanentInstance.isActive()    should "activate the most recently-selected input if the count is 1", -> -    focusInput 3 -    focusInput 1 +    NormalModeCommands.focusInput 3 +    NormalModeCommands.focusInput 1      assert.equal "third", document.activeElement.id    should "not trigger insert if there are no inputs", ->      document.getElementById("test-div").innerHTML = "" -    focusInput 1 +    NormalModeCommands.focusInput 1      assert.isFalse InsertMode.permanentInstance.isActive()  # TODO: these find prev/next link tests could be refactored into unit tests which invoke a function which has @@ -488,7 +479,7 @@ context "Find prev / next links",      <a href='#second'>next page</a>      """      stubSettings "nextPatterns", "next" -    goNext() +    NormalModeCommands.goNext()      assert.equal '#second', window.location.hash    should "match against non-word patterns", -> @@ -496,7 +487,7 @@ context "Find prev / next links",      <a href='#first'>>></a>      """      stubSettings "nextPatterns", ">>" -    goNext() +    NormalModeCommands.goNext()      assert.equal '#first', window.location.hash    should "favor matches with fewer words", -> @@ -505,14 +496,14 @@ context "Find prev / next links",      <a href='#second'>next!</a>      """      stubSettings "nextPatterns", "next" -    goNext() +    NormalModeCommands.goNext()      assert.equal '#second', window.location.hash    should "find link relation in header", ->      document.getElementById("test-div").innerHTML = """      <link rel='next' href='#first'>      """ -    goNext() +    NormalModeCommands.goNext()      assert.equal '#first', window.location.hash    should "favor link relation to text matching", -> @@ -520,14 +511,14 @@ context "Find prev / next links",      <link rel='next' href='#first'>      <a href='#second'>next</a>      """ -    goNext() +    NormalModeCommands.goNext()      assert.equal '#first', window.location.hash    should "match mixed case link relation", ->      document.getElementById("test-div").innerHTML = """      <link rel='Next' href='#first'>      """ -    goNext() +    NormalModeCommands.goNext()      assert.equal '#first', window.location.hash  createLinks = (n) -> @@ -576,93 +567,55 @@ context "Key mapping",    should "set and call command handler", ->      sendKeyboardEvent "m"      assert.isTrue @handlerCalled -    assert.equal 0, pageKeyboardEventCount    should "not call command handler for pass keys", ->      sendKeyboardEvent "p"      assert.isFalse @handlerCalled -    assert.equal 3, pageKeyboardEventCount    should "accept a count prefix with a single digit", ->      sendKeyboardEvent "2"      sendKeyboardEvent "m"      assert.equal 2, @handlerCalledCount -    assert.equal 0, pageKeyboardEventCount    should "accept a count prefix with multiple digits", ->      sendKeyboardEvent "2"      sendKeyboardEvent "0"      sendKeyboardEvent "m"      assert.equal 20, @handlerCalledCount -    assert.equal 0, pageKeyboardEventCount    should "cancel a count prefix", ->      sendKeyboardEvent "2"      sendKeyboardEvent "z"      sendKeyboardEvent "m"      assert.equal 1, @handlerCalledCount -    assert.equal 0, pageKeyboardEventCount    should "accept a count prefix for multi-key command mappings", -> -    sendKeyboardEvent "2" +    sendKeyboardEvent "5"      sendKeyboardEvent "z"      sendKeyboardEvent "p" -    assert.equal 2, @handlerCalledCount -    assert.equal 0, pageKeyboardEventCount +    assert.equal 5, @handlerCalledCount    should "cancel a key prefix", ->      sendKeyboardEvent "z"      sendKeyboardEvent "m"      assert.equal 1, @handlerCalledCount -    assert.equal 0, pageKeyboardEventCount    should "cancel a count prefix after a prefix key", ->      sendKeyboardEvent "2"      sendKeyboardEvent "z"      sendKeyboardEvent "m"      assert.equal 1, @handlerCalledCount -    assert.equal 0, pageKeyboardEventCount    should "cancel a prefix key on escape", ->      sendKeyboardEvent "z" -    sendKeyboardEvent "escape" +    sendKeyboardEvent "Escape", "keydown"      sendKeyboardEvent "p"      assert.equal 0, @handlerCalledCount -  should "not handle escape on its own", -> -    sendKeyboardEvent "escape" -    assert.equal 2, pageKeyboardEventCount -  context "Normal mode",    setup ->      initializeModeState() -  should "suppress mapped keys", -> -    sendKeyboardEvent "m" -    assert.equal 0, pageKeyboardEventCount - -  should "not suppress unmapped keys", -> -    sendKeyboardEvent "u" -    assert.equal 3, pageKeyboardEventCount - -  should "not suppress escape", -> -    sendKeyboardEvent "escape" -    assert.equal 2, pageKeyboardEventCount - -  should "not suppress passKeys", -> -    sendKeyboardEvent "p" -    assert.equal 3, pageKeyboardEventCount - -  should "suppress passKeys with a non-empty key state (a count)", -> -    sendKeyboardEvent "5" -    sendKeyboardEvent "p" -    assert.equal 0, pageKeyboardEventCount - -  should "suppress passKeys with a non-empty key state (a key)", -> -    sendKeyboardEvent "z" -    sendKeyboardEvent "p" -    assert.equal 0, pageKeyboardEventCount -    should "invoke commands for mapped keys", ->      sendKeyboardEvent "m"      assert.equal "m", commandName @@ -706,7 +659,7 @@ context "Normal mode",      assert.equal 2, commandCount    should "accept count prefixes of length 2", -> -    sendKeyboardEvent "12" +    sendKeyboardEvents "12"      sendKeyboardEvent "m"      assert.equal 12, commandCount @@ -763,19 +716,16 @@ context "Insert mode",      initializeModeState()      @insertMode = new InsertMode global: true -  should "not suppress mapped keys in insert mode", -> -    sendKeyboardEvent "m" -    assert.equal 3, pageKeyboardEventCount -    should "exit on escape", ->      assert.isTrue @insertMode.modeIsActive -    sendKeyboardEvent "escape" +    sendKeyboardEvent "Escape", "keydown"      assert.isFalse @insertMode.modeIsActive    should "resume normal mode after leaving insert mode", -> +    assert.equal null, commandCount      @insertMode.exit()      sendKeyboardEvent "m" -    assert.equal 0, pageKeyboardEventCount +    assert.equal 1, commandCount  context "Triggering insert mode",    setup -> @@ -833,7 +783,7 @@ context "Caret mode",      assert.equal "I", getSelection()    should "exit caret mode on escape", -> -    sendKeyboardEvent "escape" +    sendKeyboardEvent "Escape", "keydown"      assert.equal "", getSelection()    should "move caret with l and h", -> @@ -868,7 +818,7 @@ context "Caret mode",      assert.equal "I", getSelection()      sendKeyboardEvents "ww"      assert.equal "a", getSelection() -    sendKeyboardEvent "escape" +    sendKeyboardEvent "Escape", "keydown"      new VisualMode      assert.equal "a", getSelection() @@ -983,16 +933,14 @@ context "Mode utilities",      test = new Mode exitOnEscape: true      assert.isTrue test.modeIsActive -    sendKeyboardEvent "escape" -    assert.equal 0, pageKeyboardEventCount +    sendKeyboardEvent "Escape", "keydown"      assert.isFalse test.modeIsActive    should "not exit on escape if not enabled", ->      test = new Mode exitOnEscape: false      assert.isTrue test.modeIsActive -    sendKeyboardEvent "escape" -    assert.equal 2, pageKeyboardEventCount +    sendKeyboardEvent "Escape", "keydown"      assert.isTrue test.modeIsActive    should "exit on blur", -> @@ -1031,21 +979,21 @@ context "PostFindMode",      assert.isFalse @postFindMode.modeIsActive    should "suppress unmapped printable keys", -> -    sendKeyboardEvent "m" -    assert.equal 0, pageKeyboardEventCount +    sendKeyboardEvent "a" +    assert.equal null, commandCount    should "be deactivated on click events", ->      handlerStack.bubbleEvent "click", target: document.activeElement      assert.isFalse @postFindMode.modeIsActive    should "enter insert mode on immediate escape", -> -    sendKeyboardEvent "escape" -    assert.equal 0, pageKeyboardEventCount +    sendKeyboardEvent "Escape", "keydown" +    assert.equal null, commandCount      assert.isFalse @postFindMode.modeIsActive    should "not enter insert mode on subsequent escapes", ->      sendKeyboardEvent "a" -    sendKeyboardEvent "escape" +    sendKeyboardEvent "Escape", "keydown"      assert.isTrue @postFindMode.modeIsActive  context "WaitForEnter", @@ -1057,14 +1005,14 @@ context "WaitForEnter",    should "exit with success on Enter", ->      assert.isTrue @waitForEnter.modeIsActive      assert.isFalse @isSuccess? -    sendKeyboardEvent "enter" +    sendKeyboardEvent "Enter", "keydown"      assert.isFalse @waitForEnter.modeIsActive      assert.isTrue @isSuccess? and @isSuccess == true    should "exit without success on Escape", ->      assert.isTrue @waitForEnter.modeIsActive      assert.isFalse @isSuccess? -    sendKeyboardEvent "escape" +    sendKeyboardEvent "Escape", "keydown"      assert.isFalse @waitForEnter.modeIsActive      assert.isTrue @isSuccess? and @isSuccess == false @@ -1075,17 +1023,6 @@ context "WaitForEnter",      assert.isTrue @waitForEnter.modeIsActive      assert.isFalse @isSuccess? -context "SuppressAllKeyboardEvents", -  setup -> -    initializeModeState() - -  should "supress keyboard events", -> -    sendKeyboardEvent "a" -    assert.equal 3, pageKeyboardEventCount -    new SuppressAllKeyboardEvents -    sendKeyboardEvent "a" -    assert.equal 0, pageKeyboardEventCount -  context "GrabBackFocus",    setup ->      testContent = "<input type='text' value='some value' id='input'/>" diff --git a/tests/dom_tests/dom_tests.html b/tests/dom_tests/dom_tests.html index d2e795d1..37cd43e3 100644 --- a/tests/dom_tests/dom_tests.html +++ b/tests/dom_tests/dom_tests.html @@ -49,6 +49,7 @@      <script type="text/javascript" src="../../content_scripts/mode_key_handler.js"></script>      <script type="text/javascript" src="../../content_scripts/mode_visual.js"></script>      <script type="text/javascript" src="../../content_scripts/hud.js"></script> +    <script type="text/javascript" src="../../content_scripts/mode_normal.js"></script>      <script type="text/javascript" src="../../content_scripts/vimium_frontend.js"></script>      <script type="text/javascript" src="../shoulda.js/shoulda.js"></script> diff --git a/tests/dom_tests/phantom_runner.coffee b/tests/dom_tests/phantom_runner.coffee index 09d7d584..b91919bb 100644 --- a/tests/dom_tests/phantom_runner.coffee +++ b/tests/dom_tests/phantom_runner.coffee @@ -21,30 +21,6 @@ page.onError = (msg, trace) ->  page.onResourceError = (resourceError) ->    console.log(resourceError.errorString) -page.onCallback = (request) -> -  switch request.request -    when "keyboard" -      switch request.key -        when "escape" -          page.sendEvent "keydown", page.event.key.Escape -          page.sendEvent "keyup", page.event.key.Escape -        when "enter" -          page.sendEvent "keydown", page.event.key.Enter -          page.sendEvent "keyup", page.event.key.Enter -        when "tab" -          page.sendEvent "keydown", page.event.key.Tab -          page.sendEvent "keyup", page.event.key.Tab -        when "shift-down" -          page.sendEvent "keydown", page.event.key.Shift -        when "shift-up" -          page.sendEvent "keyup", page.event.key.Shift -        when "ctrl-down" -          page.sendEvent "keydown", page.event.key.Control -        when "ctrl-up" -          page.sendEvent "keyup", page.event.key.Control -        else -          page.sendEvent "keypress", request.key -  testfile = path.join(path.dirname(system.args[0]), 'dom_tests.html')  page.open testfile, (status) ->    if status != 'success' diff --git a/tests/unit_tests/commands_test.coffee b/tests/unit_tests/commands_test.coffee index 0e0be1d6..49dd2570 100644 --- a/tests/unit_tests/commands_test.coffee +++ b/tests/unit_tests/commands_test.coffee @@ -4,6 +4,14 @@ extend global, require "../../background_scripts/bg_utils.js"  global.Settings = {postUpdateHooks: {}, get: (-> ""), set: ->}  {Commands} = require "../../background_scripts/commands.js" +# Include mode_normal to check that all commands have been implemented. +global.KeyHandlerMode = global.Mode = {} +global.KeyboardUtils = {platform: ""} +extend global, require "../../content_scripts/link_hints.js" +extend global, require "../../content_scripts/marks.js" +extend global, require "../../content_scripts/vomnibar.js" +{NormalModeCommands} = require "../../content_scripts/mode_normal.js" +  context "Key mappings",    setup ->      @testKeySequence = (key, expectedKeyText, expectedKeyLength) -> @@ -114,6 +122,14 @@ context "Parse commands",      assert.equal "a", BgUtils.parseLines("  a  \n  b")[0]      assert.equal "b", BgUtils.parseLines("  a  \n  b")[1] -# TODO (smblott) More tests: -# - Ensure each background command has an implmentation in BackgroundCommands -# - Ensure each foreground command has an implmentation in vimium_frontent.coffee +context "Commands implemented", +  (for own command, options of Commands.availableCommands +    do (command, options) -> +      if options.background +        should "#{command} (background command)", -> +          # TODO: Import background_scripts/main.js and expose BackgroundCommands from there. +          # assert.isTrue BackgroundCommands[command] +      else +        should "#{command} (foreground command)", -> +          assert.isTrue NormalModeCommands[command] +  )... diff --git a/tests/unit_tests/exclusion_test.coffee b/tests/unit_tests/exclusion_test.coffee index f53d23f6..06e0a51f 100644 --- a/tests/unit_tests/exclusion_test.coffee +++ b/tests/unit_tests/exclusion_test.coffee @@ -29,7 +29,7 @@ isEnabledForUrl = (request) ->  context "Excluded URLs and pass keys",    setup -> -    Exclusions.postUpdateHook( +    Settings.set "exclusionRules",        [          { pattern: "http*://mail.google.com/*", passKeys: "" }          { pattern: "http*://www.facebook.com/*", passKeys: "abab" } @@ -39,7 +39,8 @@ context "Excluded URLs and pass keys",          { pattern: "http*://www.example.com/*", passKeys: "a bb c bba a" }          { pattern: "http*://www.duplicate.com/*", passKeys: "ace" }          { pattern: "http*://www.duplicate.com/*", passKeys: "bdf" } -      ]) +      ] +    Exclusions.postUpdateHook()    should "be disabled for excluded sites", ->      rule = isEnabledForUrl({ url: 'http://mail.google.com/calendar/page' }) diff --git a/tests/unit_tests/handler_stack_test.coffee b/tests/unit_tests/handler_stack_test.coffee index 7b62af07..374c235b 100644 --- a/tests/unit_tests/handler_stack_test.coffee +++ b/tests/unit_tests/handler_stack_test.coffee @@ -4,6 +4,7 @@ extend(global, require "../../lib/handler_stack.js")  context "handlerStack",    setup ->      stub global, "DomUtils", {} +    stub DomUtils, "consumeKeyup", ->      stub DomUtils, "suppressEvent", ->      stub DomUtils, "suppressPropagation", ->      @handlerStack = new HandlerStack diff --git a/tests/unit_tests/rect_test.coffee b/tests/unit_tests/rect_test.coffee index 0773dbcf..5054e029 100644 --- a/tests/unit_tests/rect_test.coffee +++ b/tests/unit_tests/rect_test.coffee @@ -201,7 +201,7 @@ context "Rect subtraction",              subtractRect = Rect.create x, y, (x + width), (y + height)              resultRects = Rect.subtract rect, subtractRect              for resultRect in resultRects -              assert.isFalse Rect.contains subtractRect, resultRect +              assert.isFalse Rect.intersects subtractRect, resultRect    should "be contained in original rect", ->      rect = Rect.create 0, 0, 3, 3 @@ -212,7 +212,7 @@ context "Rect subtraction",              subtractRect = Rect.create x, y, (x + width), (y + height)              resultRects = Rect.subtract rect, subtractRect              for resultRect in resultRects -              assert.isTrue Rect.contains rect, resultRect +              assert.isTrue Rect.intersects rect, resultRect    should "contain the  subtracted rect in the original minus the results", ->      rect = Rect.create 0, 0, 3, 3 @@ -229,60 +229,60 @@ context "Rect subtraction",              assert.isTrue (resultComplement.length == 0 or resultComplement.length == 1)              if resultComplement.length == 1                complementRect = resultComplement[0] -              assert.isTrue Rect.contains subtractRect, complementRect +              assert.isTrue Rect.intersects subtractRect, complementRect  context "Rect overlaps",    should "detect that a rect overlaps itself", ->      rect = Rect.create 2, 2, 4, 4 -    assert.isTrue Rect.rectsOverlap rect, rect +    assert.isTrue Rect.intersectsStrict rect, rect    should "detect that non-overlapping rectangles do not overlap on the left", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 0, 2, 1, 4 -    assert.isFalse Rect.rectsOverlap rect1, rect2 +    assert.isFalse Rect.intersectsStrict rect1, rect2    should "detect that non-overlapping rectangles do not overlap on the right", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 5, 2, 6, 4 -    assert.isFalse Rect.rectsOverlap rect1, rect2 +    assert.isFalse Rect.intersectsStrict rect1, rect2    should "detect that non-overlapping rectangles do not overlap on the top", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 2, 0, 2, 1 -    assert.isFalse Rect.rectsOverlap rect1, rect2 +    assert.isFalse Rect.intersectsStrict rect1, rect2    should "detect that non-overlapping rectangles do not overlap on the bottom", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 2, 5, 2, 6 -    assert.isFalse Rect.rectsOverlap rect1, rect2 +    assert.isFalse Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles on the left", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 0, 2, 2, 4 -    assert.isTrue Rect.rectsOverlap rect1, rect2 +    assert.isTrue Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles on the right", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 4, 2, 5, 4 -    assert.isTrue Rect.rectsOverlap rect1, rect2 +    assert.isTrue Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles on the top", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 2, 4, 4, 5 -    assert.isTrue Rect.rectsOverlap rect1, rect2 +    assert.isTrue Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles on the bottom", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 2, 0, 4, 2 -    assert.isTrue Rect.rectsOverlap rect1, rect2 +    assert.isTrue Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles when second rectangle is contained in first", ->      rect1 = Rect.create 1, 1, 4, 4      rect2 = Rect.create 2, 2, 3, 3 -    assert.isTrue Rect.rectsOverlap rect1, rect2 +    assert.isTrue Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles when first rectangle is contained in second", ->      rect1 = Rect.create 1, 1, 4, 4      rect2 = Rect.create 2, 2, 3, 3 -    assert.isTrue Rect.rectsOverlap rect2, rect1 +    assert.isTrue Rect.intersectsStrict rect2, rect1 diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index bea13df3..44ec4f66 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -41,8 +41,6 @@ exports.chrome =      getViews: -> []    tabs: -    onSelectionChanged: -      addListener: () -> true      onUpdated:        addListener: () -> true      onAttached: @@ -51,8 +49,6 @@ exports.chrome =        addListener: () -> true      onRemoved:        addListener: () -> true -    onActiveChanged: -      addListener: () -> true      onActivated:        addListener: () -> true      onReplaced: @@ -64,6 +60,8 @@ exports.chrome =        addListener: () ->      onReferenceFragmentUpdated:        addListener: () -> +    onCommitted: +      addListener: () ->    windows:      onRemoved: diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee index 04cf34b6..2794a6d7 100644 --- a/tests/unit_tests/utils_test.coffee +++ b/tests/unit_tests/utils_test.coffee @@ -22,6 +22,9 @@ context "isUrl",      assert.isTrue Utils.isUrl "illinois.state.museum"      assert.isTrue Utils.isUrl "eqt5g4fuenphqinx.onion" +    # Internal URLs. +    assert.isTrue Utils.isUrl "moz-extension://c66906b4-3785-4a60-97bc-094a6366017e/pages/options.html" +    should "reject invalid URLs", ->      assert.isFalse Utils.isUrl "a.x"      assert.isFalse Utils.isUrl "www-domain-tld" @@ -135,26 +138,6 @@ context "distinctCharacters",    should "eliminate duplicate characters", ->      assert.equal "abc", Utils.distinctCharacters "bbabaabbacabbbab" -context "invokeCommandString", -  setup -> -    @beenCalled = false -    window.singleComponentCommand = => @beenCalled = true -    window.twoComponentCommand = command: window.singleComponentCommand - -  tearDown -> -    delete window.singleComponentCommand -    delete window.twoComponentCommand - -  should "invoke single-component commands", -> -    assert.isFalse @beenCalled -    Utils.invokeCommandString "singleComponentCommand" -    assert.isTrue @beenCalled - -  should "invoke multi-component commands", -> -    assert.isFalse @beenCalled -    Utils.invokeCommandString "twoComponentCommand.command" -    assert.isTrue @beenCalled -  context "escapeRegexSpecialCharacters",    should "escape regexp special characters", ->      str = "-[]/{}()*+?.^$|" | 
