diff options
48 files changed, 3476 insertions, 1401 deletions
diff --git a/.travis.yml b/.travis.yml index 6393bfaf..c186f393 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ node_js: 0.10 before_install: - "npm install -g coffee-script" - "npm install path" + - "npm install util" - "cake build" script: "cake test" notifications: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9382a020..a417caf5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,34 @@ reports: When you're done with your changes, send us a pull request on Github. Feel free to include a change to the CREDITS file with your patch. +Vimium design goals +------------------- + +When improving Vimium it's helpful to know what design goals we're optimizing for. + +The core goal is to make it easy to navigate the web using just the keyboard. When people first start using +Vimium, it provides an incredibly powerful workflow improvement and it makes them feel awesome. And it turns +out that Vimium is applicable to a huge, broad population of people, not just users of Vim, which is great. + +A secondary goal is to make Vimium approachable, or in other words, to minimize the barriers which will +prevent a new user from feeling awesome. Many of Vimium's users haven't used Vim before (about 1 in 5 app +store reviews say this), and most people have strong web browsing habits forged from years of browsing that +they rely on. Given that, it's a great experience when Vimium feels like a natural addition to Chrome which +augments but doesn't break their current browsing habits. + +In some ways, making software approachable is even harder than just enabling the core use case. But in this +area, Vimium really shines. It's approachable today because: + +1. It's simple to understand (even if you're not very familiar with Vim). The Vimium video shows you all you + need to know to start using Vimium and feel awesome. +2. The core feature set works in almost all cases on all sites, so Vimium feels reliable. +3. Requires no configuration or doc-reading before it's useful. Just watch the video or hit `?`. +4. Doesn't drastically change the way Chrome looks or behaves. You can transition into using Vimium piecemeal; + you don't need to jump in whole-hog from the start. +5. The core feature set isn't overwhelming. This is easy to degrade as we evolve Vimium, so it requires active + effort to maintain this feel. +6. Developers find the code is relatively simple and easy to jump into, so we have an active dev community. + ## What makes for a good feature request/contribution to Vimium? Good features: @@ -96,23 +124,3 @@ We use these guidelines, in addition to the code complexity, when deciding wheth If you're worried that a feature you plan to build won't be a good fit for core Vimium, just open a github issue for discussion or send an email to the Vimium mailing list. - -## How to release Vimium to the Chrome Store - -This process is currently only done by Phil or Ilya. - -1. Increment the version number in manifest.json -2. Update the Changelog in README.md - - You can see a summary of commits since the last version: `git log --oneline v1.45..` - -3. Push your commits -4. Create a git tag for this newly released version - - git tag -a v1.45 -m "v1.45 release" - -5. Run `cake package` -6. Take the distributable found in `dist` and upload it - [here](https://chrome.google.com/webstore/developer/dashboard) -7. Update the description in the Chrome store to include the latest version's release notes -8. Celebrate @@ -3,8 +3,8 @@ Vimium - The Hacker's Browser [](https://travis-ci.org/philc/vimium) -Vimium is a Chrome extension that provides keyboard-based navigation and control in the spirit of the Vim -editor. +Vimium is a Chrome extension that provides keyboard-based navigation and control of the web in the spirit of +the Vim editor. __Installation instructions:__ @@ -15,14 +15,16 @@ Please see [CONTRIBUTING.md](https://github.com/philc/vimium/blob/master/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 (hit `?`) or via the button next to Vimium on +The Options page can be reached via a link on the help dialog (type `?`) or via the button next to Vimium on the Chrome Extensions page (`chrome://extensions`). 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 instructions on customizing these bindings. +respectively. 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 `?`. Navigating the current page: @@ -90,7 +92,7 @@ Additional advanced browsing commands: Vimium supports command repetition so, for example, hitting '5t' will open 5 tabs in rapid succession. `<ESC>` (or `<c-[>`) will clear any partial commands in the queue and will also exit insert and find modes. -There are some advanced commands which aren't documented here. Refer to the help dialog (type `?`) for a full +There are some advanced commands which aren't documented here; refer to the help dialog (type `?`) for a full list. Custom Key Mappings @@ -138,6 +140,20 @@ Please see [CONTRIBUTING.md](https://github.com/philc/vimium/blob/master/CONTRIB Release Notes ------------- +1.49 (2014-12-16) + +- An option to toggle smooth scrolling. +- Make Vimium work on older versions of Chrome. + +1.46, 1.47, 1.48 (2014-12-15) + +- Site-specific excluded keys: you can disable some Vimium key bindings on sites like gmail.com, so you can use the key bindings provided by the site itself. +- Smooth scrolling. +- The Vomnibar now orders tabs by recency. Use this to quickly switch between your most recently-used tabs. +- New commands: "close tabs to the left", "close tabs to the right", "close all other tabs". +- Usability improvements. +- Bug fixes. + 1.45 (2014-07-20) - Vimium's settings are now synced across computers. @@ -145,12 +161,12 @@ Release Notes - Vomnibar can now use [search engine shortcuts](https://github.com/philc/vimium/wiki/Search-Engines), similar to Chrome's Omnibar. - Due to significant ranking improvements, Vomnibar's search results are now even more helpful. - When reopening a closed tab, its history is now preserved. -- Bugfixes. +- Bug fixes. 1.44 (2013-11-06) - Add support for recent versions of Chromium. -- Bugfixes. +- Bug fixes. 1.43 (2013-05-18) @@ -162,25 +178,25 @@ Release Notes - Added "LinkHints.activateModeToOpenIncognito", currently an advanced, unbound command. - Disallowed repeat tab closings, since this causes trouble for many people. - Update our Chrome APIs so Vimium works on Chrome 28+. -- Bugfixes. +- Bug fixes. 1.42 (2012-11-03) -- Bugfixes. +- Bug fixes. 1.41 (2012-10-27) -- Bugfixes. +- Bug fixes. 1.40 (2012-10-27) -- Bugfixes. +- Bug fixes. - Added options for search engines and regex find. - Pressing unmapped keys in hints mode now deactivates the mode. 1.39 (2012-09-09) -- Bugfixes. +- Bug fixes. 1.38 (2012-09-08) @@ -188,7 +204,7 @@ Release Notes - Add a browser icon to quickly add sites to Vimium's exclude list. - Restyle options page. - `gi` now launches a new mode that allows the user to tab through the input elements on the page. -- Bugfixes. +- Bug fixes. 1.37 (2012-07-07) @@ -201,11 +217,11 @@ Release Notes 1.35 (2012-07-05) -- Bugfixes. +- Bug fixes. 1.34 (2012-07-03) -- A bugfix for bookmarklets in Vomnibar. +- A bug fix for bookmarklets in Vomnibar. 1.33 (2012-07-02) @@ -245,11 +261,11 @@ Release Notes - Support for opening bookmarks (`b` and `B`). - Support for contenteditable text boxes. -- Speed improvements and bugfixes. +- Speed improvements and bug fixes. 1.27 (2011-03-24) -- Improvements and bugfixes. +- Improvements and bug fixes. 1.26 (2011-02-17) @@ -264,11 +280,11 @@ Release Notes - Many of the less-used commands are now marked as "advanced" and hidden in the help dialog by default, so that the core command set is more focused and approachable. - Improvements to link hinting. -- Bugfixes. +- Bug fixes. 1.21 (2010-10-24) -- Critical bugfix for an excluded URLs regression due to frame support. +- Critical bug fix for an excluded URLs regression due to frame support. 1.20 (2010-10-24) @@ -315,7 +331,7 @@ does not support command repetition. - Make the CSS used by the link hints configurable. It's under Advanced Options. - Add a notification linking to the changelog when Vimium is updated in the background. -- Link-hinting performance improvements and bugfixes. +- Link-hinting performance improvements and bug fixes. - Ctrl+D and Ctrl+U now scroll by 1/2 page instead of a fixed amount, to mirror Vim's behavior. 1.14 (2010-01-21) @@ -325,7 +341,7 @@ does not support command repetition. 1.13 (2010-01-21) - `<c-f>` and `<c-b>` are now mapped to scroll a full page up or down respectively. -- Bugfixes related to entering insert mode when the page first loads, and when focusing Flash embeds. +- Bug fixes related to entering insert mode when the page first loads, and when focusing Flash embeds. - Added command listing to the Options page for easy reference. - J & K have reversed for tab switching: J goes left and K goes right. - `<c-[>` is now equivalent to ESC, to match the behavior of VIM. diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index 10fa323b..114f46ce 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -58,7 +58,7 @@ Commands = for line in lines continue if (line[0] == "\"" || line[0] == "#") - splitLine = line.split(/\s+/) + splitLine = line.replace(/\s+$/, "").split(/\s+/) lineCommand = splitLine[0] @@ -111,6 +111,7 @@ Commands = "goUp", "goToRoot", "enterInsertMode", + "enterVisualMode", "focusInput", "LinkHints.activateMode", "LinkHints.activateModeToOpenInNewTab", @@ -195,6 +196,7 @@ defaultKeyMappings = "gs": "toggleViewSource" "i": "enterInsertMode" + "v": "enterVisualMode" "H": "goBack" "L": "goForward" @@ -207,8 +209,6 @@ defaultKeyMappings = "F": "LinkHints.activateModeToOpenInNewTab" "<a-f>": "LinkHints.activateModeWithQueue" - "af": "LinkHints.activateModeToDownloadLink" - "/": "enterFindMode" "n": "performFind" "N": "performBackwardsFind" @@ -285,6 +285,7 @@ commandDescriptions = openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true, repeatLimit: 20 }] enterInsertMode: ["Enter insert mode", { noRepeat: true }] + enterVisualMode: ["Enter visual mode (not yet implemented)", { noRepeat: true }] focusInput: ["Focus the first text box on the page. Cycle between them using tab", { passCountToFunction: true }] diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index dc5519d5..d6402019 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -324,11 +324,17 @@ class SearchEngineCompleter searchEngines: {} filter: (queryTerms, onComplete) -> - searchEngineMatch = this.getSearchEngineMatches(queryTerms[0]) + {url: url, description: description} = @getSearchEngineMatches queryTerms suggestions = [] - if searchEngineMatch - searchEngineMatch = searchEngineMatch.replace(/%s/g, queryTerms[1..].join(" ")) - suggestion = new Suggestion(queryTerms, "search", searchEngineMatch, queryTerms[0] + ": " + queryTerms[1..].join(" "), @computeRelevancy) + if url + url = url.replace(/%s/g, Utils.createSearchQuery queryTerms[1..]) + if description + type = description + query = queryTerms[1..].join " " + else + type = "search" + query = queryTerms[0] + ": " + queryTerms[1..].join(" ") + suggestion = new Suggestion(queryTerms, type, url, query, @computeRelevancy) suggestions.push(suggestion) onComplete(suggestions) @@ -337,8 +343,8 @@ class SearchEngineCompleter refresh: -> this.searchEngines = root.Settings.getSearchEngines() - getSearchEngineMatches: (queryTerm) -> - this.searchEngines[queryTerm] + getSearchEngineMatches: (queryTerms) -> + (1 < queryTerms.length and @searchEngines[queryTerms[0]]) or {} # A completer which calls filter() on many completers, aggregates the results, ranks them, and returns the top # 10. Queries from the vomnibar frontend script come through a multi completer. diff --git a/background_scripts/exclusions.coffee b/background_scripts/exclusions.coffee index 3a8ef1e7..55ced3ef 100644 --- a/background_scripts/exclusions.coffee +++ b/background_scripts/exclusions.coffee @@ -6,21 +6,36 @@ RegexpCache = if regexp = @cache[pattern] regexp else - @cache[pattern] = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$") + @cache[pattern] = + # We use try/catch to ensure that a broken regexp doesn't wholly cripple Vimium. + try + new RegExp("^" + pattern.replace(/\*/g, ".*") + "$") + catch + /^$/ # Match the empty string. # The Exclusions class manages the exclusion rule setting. # An exclusion is an object with two attributes: pattern and passKeys. -# The exclusions are an array of such objects (because the order matters). +# The exclusions are an array of such objects. root.Exclusions = Exclusions = + # Make RegexpCache, which is required on the page popup, accessible via the Exclusions object. + RegexpCache: RegexpCache rules: Settings.get("exclusionRules") - # Return the first exclusion rule matching the URL, or null. - getRule: (url) -> - for rule in @rules - return rule if url.match(RegexpCache.get(rule.pattern)) - return null + # 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) -> + matches = (rule for rule in rules when rule.pattern and 0 <= url.search(RegexpCache.get(rule.pattern))) + # An absolute exclusion rule (with no passKeys) takes priority. + for rule in matches + return rule unless rule.passKeys + if 0 < matches.length + pattern: (rule.pattern for rule in matches).join " | " # Not used; for debugging only. + passKeys: Utils.distinctCharacters (rule.passKeys for rule in matches).join "" + else + null setRules: (rules) -> # Callers map a rule to null to have it deleted, and rules without a pattern are useless. @@ -30,19 +45,6 @@ root.Exclusions = Exclusions = postUpdateHook: (rules) -> @rules = rules - # Update an existing rule or add a new rule. - updateOrAdd: (newRule) -> - seen = false - @rules.push(newRule) - @setRules @rules.map (rule) -> - if rule.pattern == newRule.pattern - if seen then null else seen = newRule - else - rule - - remove: (pattern) -> - @setRules(@rules.filter((rule) -> rule and rule.pattern != pattern)) - # Development and debug only. # Enable this (temporarily) to restore legacy exclusion rules from backup. if false and Settings.has("excludedUrlsBackup") diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index 3ec618c9..c1c8dfc8 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -19,6 +19,11 @@ namedKeyRegex = /^(<(?:[amc]-.|(?:[amc]-)?[a-z0-9]{2,5})>)(.*)$/ selectionChangedHandlers = [] tabLoadedHandlers = {} # tabId -> function() +# A secret, available only within the current instantiation of Vimium. The secret is big, likely unguessable +# in practice, but less than 2^31. +chrome.storage.local.set + vimiumSecret: Math.floor Math.random() * 2000000000 + completionSources = bookmarks: new BookmarkCompleter() history: new HistoryCompleter() @@ -75,26 +80,10 @@ getCurrentTabUrl = (request, sender) -> sender.tab.url root.isEnabledForUrl = isEnabledForUrl = (request) -> rule = Exclusions.getRule(request.url) { - rule: rule isEnabledForUrl: not rule or rule.passKeys passKeys: rule?.passKeys or "" } -# Called by the popup UI. -# If the URL pattern matches an existing rule, then the existing rule is updated. Otherwise, a new rule is created. -root.addExclusionRule = (pattern,passKeys) -> - if pattern = pattern.trim() - Exclusions.updateOrAdd({ pattern: pattern, passKeys: passKeys }) - chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, - (tabs) -> updateActiveState(tabs[0].id)) - -# Called by the popup UI. Remove all existing exclusion rules with this pattern. -root.removeExclusionRule = (pattern) -> - if pattern = pattern.trim() - Exclusions.remove(pattern) - chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, - (tabs) -> updateActiveState(tabs[0].id)) - saveHelpDialogSettings = (request) -> Settings.set("helpDialog_showAdvancedCommands", request.showAdvancedCommands) @@ -350,10 +339,30 @@ updateOpenTabs = (tab) -> setBrowserActionIcon = (tabId,path) -> chrome.browserAction.setIcon({ tabId: tabId, path: path }) +chrome.browserAction.setBadgeBackgroundColor + # This is Vimium blue (from the icon). + # color: [102, 176, 226, 255] + # This is a slightly darker blue. It makes the badge more striking in the corner of the eye, and the symbol + # easier to read. + color: [82, 156, 206, 255] + +setBadge = do -> + current = null + timer = null + updateBadge = (badge) -> -> chrome.browserAction.setBadgeText text: badge + (request) -> + badge = request.badge + if badge? and badge != current + current = badge + clearTimeout timer if timer + # We wait a few moments. This avoids badge flicker when there are rapid changes. + timer = setTimeout updateBadge(badge), 50 + # Updates the browserAction icon to indicate whether Vimium is enabled or disabled on the current page. # Also propagates new enabled/disabled/passkeys state to active window, if necessary. # This lets you disable Vimium on a page without needing to reload. -updateActiveState = (tabId) -> +# Exported via root because it's called from the page popup. +root.updateActiveState = updateActiveState = (tabId) -> enabledIcon = "icons/browser_action_enabled.png" disabledIcon = "icons/browser_action_disabled.png" partialIcon = "icons/browser_action_partial.png" @@ -377,6 +386,7 @@ updateActiveState = (tabId) -> else # We didn't get a response from the front end, so Vimium isn't running. setBrowserActionIcon(tabId,disabledIcon) + setBadge {badge: ""} handleUpdateScrollPosition = (request, sender) -> updateScrollPosition(sender.tab, request.scrollX, request.scrollY) @@ -624,24 +634,26 @@ portHandlers = filterCompleter: filterCompleter sendRequestHandlers = - getCompletionKeys: getCompletionKeysRequest, - getCurrentTabUrl: getCurrentTabUrl, - openUrlInNewTab: openUrlInNewTab, - openUrlInIncognito: openUrlInIncognito, - openUrlInCurrentTab: openUrlInCurrentTab, - openOptionsPageInNewTab: openOptionsPageInNewTab, - registerFrame: registerFrame, - unregisterFrame: unregisterFrame, - frameFocused: handleFrameFocused, - upgradeNotificationClosed: upgradeNotificationClosed, - updateScrollPosition: handleUpdateScrollPosition, - copyToClipboard: copyToClipboard, - isEnabledForUrl: isEnabledForUrl, - saveHelpDialogSettings: saveHelpDialogSettings, - selectSpecificTab: selectSpecificTab, - refreshCompleter: refreshCompleter, - createMark: Marks.create.bind(Marks), + getCompletionKeys: getCompletionKeysRequest + getCurrentTabUrl: getCurrentTabUrl + openUrlInNewTab: openUrlInNewTab + openUrlInIncognito: openUrlInIncognito + openUrlInCurrentTab: openUrlInCurrentTab + openOptionsPageInNewTab: openOptionsPageInNewTab + registerFrame: registerFrame + unregisterFrame: unregisterFrame + frameFocused: handleFrameFocused + nextFrame: (request) -> BackgroundCommands.nextFrame 1, request.frameId + upgradeNotificationClosed: upgradeNotificationClosed + updateScrollPosition: handleUpdateScrollPosition + copyToClipboard: copyToClipboard + isEnabledForUrl: isEnabledForUrl + saveHelpDialogSettings: saveHelpDialogSettings + selectSpecificTab: selectSpecificTab + refreshCompleter: refreshCompleter + createMark: Marks.create.bind(Marks) gotoMark: Marks.goto.bind(Marks) + setBadge: setBadge # Convenience function for development use. window.runTests = -> open(chrome.runtime.getURL('tests/dom_tests/dom_tests.html')) diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index d6e8fcde..2fc3b43d 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -46,13 +46,19 @@ root.Settings = Settings = # this is a map that we use to store our search engines for use. searchEnginesMap: {} - # this parses the search engines settings and clears the old searchEngines and sets the new one + # Parse the custom search engines setting and cache it. parseSearchEngines: (searchEnginesText) -> @searchEnginesMap = {} - # find the split pairs by first splitting by line then splitting on the first `: ` - split_pairs = ( pair.split( /: (.+)/, 2) for pair in searchEnginesText.split( /\n/ ) when pair[0] != "#" ) - @searchEnginesMap[a[0]] = a[1] for a in split_pairs - @searchEnginesMap + for line in searchEnginesText.split /\n/ + tokens = line.trim().split /\s+/ + continue if tokens.length < 2 or tokens[0].startsWith('"') or tokens[0].startsWith("#") + keywords = tokens[0].split ":" + continue unless keywords.length == 2 and not keywords[1] # So, like: [ "w", "" ]. + @searchEnginesMap[keywords[0]] = + url: tokens[1] + description: tokens[2..].join(" ") + + # Fetch the search-engine map, building it if necessary. getSearchEngines: -> this.parseSearchEngines(@get("searchEngines") || "") if Object.keys(@searchEnginesMap).length == 0 @searchEnginesMap @@ -61,7 +67,8 @@ root.Settings = Settings = # or strings defaults: scrollStepSize: 60 - keyMappings: "# Insert your prefered key mappings here." + smoothScroll: true + keyMappings: "# Insert your preferred key mappings here." linkHintCharacters: "sadfjklewcmpgh" linkHintNumbers: "0123456789" filterLinkHints: false @@ -104,7 +111,7 @@ root.Settings = Settings = # default/fall back search engine searchUrl: "http://www.google.com/search?q=" # put in an example search engine - searchEngines: "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s" + searchEngines: "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s wikipedia" newTabUrl: "chrome://newtab" settingsVersion: Utils.getCurrentVersion() diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee index 24bd7126..2abfa001 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -8,13 +8,16 @@ # In 'filter' mode, our link hints are numbers, and the user can narrow down the range of possibilities by # typing the text of the link itself. # -OPEN_IN_CURRENT_TAB = {} -OPEN_IN_NEW_BG_TAB = {} -OPEN_IN_NEW_FG_TAB = {} -OPEN_WITH_QUEUE = {} -COPY_LINK_URL = {} -OPEN_INCOGNITO = {} -DOWNLOAD_LINK_URL = {} +# The "name" property below is a short-form name to appear in the link-hints mode name. Debugging only. The +# key appears in the mode's badge. +# +OPEN_IN_CURRENT_TAB = { name: "curr-tab", key: "" } +OPEN_IN_NEW_BG_TAB = { name: "bg-tab", key: "B" } +OPEN_IN_NEW_FG_TAB = { name: "fg-tab", key: "F" } +OPEN_WITH_QUEUE = { name: "queue", key: "Q" } +COPY_LINK_URL = { name: "link", key: "C" } +OPEN_INCOGNITO = { name: "incognito", key: "I" } +DOWNLOAD_LINK_URL = { name: "download", key: "D" } LinkHints = hintMarkerContainingDiv: null @@ -36,17 +39,6 @@ LinkHints = # init: -> - # - # Generate an XPath describing what a clickable element is. - # The final expression will be something like "//button | //xhtml:button | ..." - # We use translate() instead of lower-case() because Chrome only supports XPath 1.0. - # - clickableElementsXPath: DomUtils.makeXPath( - ["a", "area[@href]", "textarea", "button", "select", - "input[not(@type='hidden' or @disabled or @readonly)]", - "*[@onclick or @tabindex or @role='link' or @role='button' or contains(@class, 'button') or " + - "@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"]) - # We need this as a top-level function because our command system doesn't yet support arguments. activateModeToOpenInNewTab: -> @activateMode(OPEN_IN_NEW_BG_TAB) activateModeToOpenInNewForegroundTab: -> @activateMode(OPEN_IN_NEW_FG_TAB) @@ -73,13 +65,13 @@ LinkHints = @hintMarkerContainingDiv = DomUtils.addElementList(hintMarkers, { id: "vimiumHintMarkerContainer", className: "vimiumReset" }) - # handlerStack is declared by vimiumFrontend.js - @handlerId = handlerStack.push({ + @hintMode = new Mode + name: "hint/#{mode.name}" + badge: "#{mode.key}?" keydown: @onKeyDownInMode.bind(this, hintMarkers), # trap all key events keypress: -> false keyup: -> false - }) setOpenLinkMode: (@mode) -> if @mode is OPEN_IN_NEW_BG_TAB or @mode is OPEN_IN_NEW_FG_TAB or @mode is OPEN_WITH_QUEUE @@ -136,45 +128,128 @@ LinkHints = marker # - # Returns all clickable elements that are not hidden and are in the current viewport. - # We prune invisible elements partly for performance reasons, but moreso it's to decrease the number - # of digits needed to enumerate all of the links on screen. + # Determine whether the element is visible and clickable. If it is, find the rect bounding the element in + # the viewport. There may be more than one part of element which is clickable (for example, if it's an + # image), therefore we always return a array of element/rect pairs (which may also be a singleton or empty). # - getVisibleClickableElements: -> - resultSet = DomUtils.evaluateXPath(@clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) - + getVisibleClickable: (element) -> + tagName = element.tagName.toLowerCase() + isClickable = false + onlyHasTabIndex = false visibleElements = [] - # Find all visible clickable elements. - for i in [0...resultSet.snapshotLength] by 1 - element = resultSet.snapshotItem(i) - clientRect = DomUtils.getVisibleClientRect(element, clientRect) - if (clientRect != null) - visibleElements.push({element: element, rect: clientRect}) - - if (element.localName == "area") - map = element.parentElement - continue unless map - img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']") - continue unless img - imgClientRects = img.getClientRects() - continue if (imgClientRects.length == 0) - c = element.coords.split(/,/) - coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)] - rect = { - top: imgClientRects[0].top + coords[1], - left: imgClientRects[0].left + coords[0], - right: imgClientRects[0].left + coords[2], - bottom: imgClientRects[0].top + coords[3], - width: coords[2] - coords[0], - height: coords[3] - coords[1] - } - - visibleElements.push({element: element, rect: rect}) + # Insert area elements that provide click functionality to an img. + if tagName == "img" + mapName = element.getAttribute "usemap" + if mapName + imgClientRects = element.getClientRects() + mapName = mapName.replace(/^#/, "").replace("\"", "\\\"") + map = document.querySelector "map[name=\"#{mapName}\"]" + if map and imgClientRects.length > 0 + areas = map.getElementsByTagName "area" + areasAndRects = DomUtils.getClientRectsForAreas imgClientRects[0], areas + visibleElements.push areasAndRects... + + # Check aria properties to see if the element should be ignored. + if (element.getAttribute("aria-hidden")?.toLowerCase() in ["", "true"] or + element.getAttribute("aria-disabled")?.toLowerCase() in ["", "true"]) + return [] # This element should never have a link hint. + + # 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("class")?.toLowerCase().indexOf("button") >= 0 or + element.getAttribute("contentEditable")?.toLowerCase() in ["", "contentEditable", "true"]) + isClickable = true + + # Check for jsaction event listeners on the element. + if element.hasAttribute "jsaction" + jsactionRules = element.getAttribute("jsaction").split(";") + for jsactionRule in jsactionRules + ruleSplit = jsactionRule.split ":" + isClickable ||= ruleSplit[0] == "click" or (ruleSplit.length == 1 and ruleSplit[0] != "none") + + # Check for tagNames which are natively clickable. + switch tagName + when "a" + isClickable = true + when "textarea" + isClickable ||= not element.disabled and not element.readOnly + when "input" + isClickable ||= not (element.getAttribute("type")?.toLowerCase() == "hidden" or + element.disabled or + (element.readOnly and DomUtils.isSelectable element)) + when "button", "select" + isClickable ||= not element.disabled + + # Elements with tabindex are sometimes useful, but usually not. We can treat them as second class + # citizens when it improves UX, so take special note of them. + tabIndexValue = element.getAttribute("tabindex") + tabIndex = if tabIndexValue == "" then 0 else parseInt tabIndexValue + unless isClickable or isNaN(tabIndex) or tabIndex < 0 + isClickable = onlyHasTabIndex = true + + if isClickable + clientRect = DomUtils.getVisibleClientRect element + if clientRect != null + visibleElements.push {element: element, rect: clientRect, secondClassCitizen: onlyHasTabIndex} visibleElements # + # Returns all clickable elements that are not hidden and are in the current viewport, along with rectangles + # at which (parts of) the elements are displayed. + # In the process, we try to find rects where elements do not overlap so that link hints are unambiguous. + # Because of this, the rects returned will frequently *NOT* be equivalent to the rects for the whole + # element. + # + getVisibleClickableElements: -> + elements = document.documentElement.getElementsByTagName "*" + visibleElements = [] + + # The order of elements here is important; they should appear in the order they are in the DOM, so that + # we can work out which element is on top when multiple elements overlap. Detecting elements in this loop + # is the sensible, efficient way to ensure this happens. + # NOTE(mrmr1993): Our previous method (combined XPath and DOM traversal for jsaction) couldn't provide + # this, so it's necessary to check whether elements are clickable in order, as we do below. + for element in elements + visibleElement = @getVisibleClickable element + visibleElements.push visibleElement... + + # TODO(mrmr1993): Consider z-index. z-index affects behviour as follows: + # * The document has a local stacking context. + # * An element with z-index specified + # - sets its z-order position in the containing stacking context, and + # - creates a local stacking context containing its children. + # * An element (1) is shown above another element (2) if either + # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), the + # ancestor of (1) has a higher z-index than the ancestor of (2); or + # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), + # + the ancestors of (1) and (2) have equal z-index, and + # + the ancestor of (1) appears later in the DOM than the ancestor of (2). + # + # Remove rects from elements where another clickable element lies above it. + nonOverlappingElements = [] + # Traverse the DOM from first to last, since later elements show above earlier elements. + visibleElements = visibleElements.reverse() + while visibleElement = visibleElements.pop() + rects = [visibleElement.rect] + for {rect: negativeRect} in visibleElements + # Subtract negativeRect from every rect in rects, and concatenate the arrays of rects that result. + rects = [].concat (rects.map (rect) -> Rect.subtract rect, negativeRect)... + if rects.length > 0 + nonOverlappingElements.push {element: visibleElement.element, rect: rects[0]} + else + # Every part of the element is covered by some other element, so just insert the whole element's + # rect. Except for elements with tabIndex set (second class citizens); these are often more trouble + # than they're worth. + # TODO(mrmr1993): This is probably the wrong thing to do, but we don't want to stop being able to + # click some elements that we could click before. + nonOverlappingElements.push visibleElement unless visibleElement.secondClassCitizen + + nonOverlappingElements + + # # Handles shift and esc keys. The other keys are passed to getMarkerMatcher().matchHintsByKey. # onKeyDownInMode: (hintMarkers, event) -> @@ -182,21 +257,31 @@ LinkHints = if ((event.keyCode == keyCodes.shiftKey or event.keyCode == keyCodes.ctrlKey) and (@mode == OPEN_IN_CURRENT_TAB or + @mode == OPEN_WITH_QUEUE or @mode == OPEN_IN_NEW_BG_TAB or @mode == OPEN_IN_NEW_FG_TAB)) - # Toggle whether to open link in a new or current tab. - prev_mode = @mode - - if event.keyCode == keyCodes.shiftKey - @setOpenLinkMode(if @mode is OPEN_IN_CURRENT_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_CURRENT_TAB) - - else # event.keyCode == keyCodes.ctrlKey - @setOpenLinkMode(if @mode is OPEN_IN_NEW_FG_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_NEW_FG_TAB) + # Toggle whether to open the link in a new or current tab. + previousMode = @mode + keyCode = event.keyCode + + switch keyCode + when keyCodes.shiftKey + @setOpenLinkMode(if @mode is OPEN_IN_CURRENT_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_CURRENT_TAB) + when keyCodes.ctrlKey + @setOpenLinkMode(if @mode is OPEN_IN_NEW_FG_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_NEW_FG_TAB) + + handlerStack.push + keyup: (event) => + if event.keyCode == keyCode + @setOpenLinkMode previousMode if @isActive + handlerStack.remove() + true # TODO(philc): Ignore keys that have modifiers. if (KeyboardUtils.isEscape(event)) + DomUtils.suppressKeyupAfterEscape handlerStack @deactivateMode() - else + else if (event.keyCode != keyCodes.shiftKey and event.keyCode != keyCodes.ctrlKey) keyResult = @getMarkerMatcher().matchHintsByKey(hintMarkers, event) linksMatched = keyResult.linksMatched delay = keyResult.delay ? 0 @@ -258,7 +343,7 @@ LinkHints = if (LinkHints.hintMarkerContainingDiv) DomUtils.removeElement LinkHints.hintMarkerContainingDiv LinkHints.hintMarkerContainingDiv = null - handlerStack.remove @handlerId + @hintMode.exit() HUD.hide() @isActive = false diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee new file mode 100644 index 00000000..acc3978e --- /dev/null +++ b/content_scripts/mode.coffee @@ -0,0 +1,202 @@ +# +# A mode implements a number of keyboard (and possibly other) event handlers which are pushed onto the handler +# stack when the mode is activated, and popped off when it is deactivated. The Mode class constructor takes a +# single argument "options" which can define (amongst other things): +# +# name: +# A name for this mode. +# +# badge: +# A badge (to appear on the browser popup). +# Optional. Define a badge if the badge is constant; for example, in find mode the badge is always "/". +# Otherwise, do not define a badge, but instead override the updateBadge method; for example, in passkeys +# mode, the badge may be "P" or "", depending on the configuration state. Or, if the mode *never* shows a +# badge, then do neither. +# +# keydown: +# keypress: +# keyup: +# Key handlers. Optional: provide these as required. The default is to continue bubbling all key events. +# +# Further options are described in the constructor, below. +# +# Additional handlers associated with a mode can be added by using the push method. For example, if a mode +# responds to "focus" events, then push an additional handler: +# @push +# "focus": (event) => .... +# Such handlers are removed when the mode is deactivated. +# +# The following events can be handled: +# keydown, keypress, keyup, click, focus and blur + +# Debug only. +count = 0 + +class Mode + # If Mode.debug is true, then we generate a trace of modes being activated and deactivated on the console. + debug: false + @modes: [] + + # Constants; short, readable names for the return values expected by handlerStack.bubbleEvent. + continueBubbling: true + suppressEvent: false + stopBubblingAndTrue: handlerStack.stopBubblingAndTrue + stopBubblingAndFalse: handlerStack.stopBubblingAndFalse + restartBubbling: handlerStack.restartBubbling + + constructor: (@options = {}) -> + @handlers = [] + @exitHandlers = [] + @modeIsActive = true + @badge = @options.badge || "" + @name = @options.name || "anonymous" + + @count = ++count + @id = "#{@name}-#{@count}" + @log "activate:", @id + + @push + keydown: @options.keydown || null + keypress: @options.keypress || null + keyup: @options.keyup || null + updateBadge: (badge) => @alwaysContinueBubbling => @updateBadge badge + + # If @options.exitOnEscape is truthy, then the mode will exit when the escape key is pressed. + if @options.exitOnEscape + # Note. This handler ends up above the mode's own key handlers on the handler stack, so it takes + # priority. + @push + _name: "mode-#{@id}/exitOnEscape" + "keydown": (event) => + return @continueBubbling unless KeyboardUtils.isEscape event + DomUtils.suppressKeyupAfterEscape handlerStack + @exit event, event.srcElement + @suppressEvent + + # If @options.exitOnBlur is truthy, then it should be an element. The mode will exit when that element + # loses the focus. + if @options.exitOnBlur + @push + _name: "mode-#{@id}/exitOnBlur" + "blur": (event) => @alwaysContinueBubbling => @exit() if event.target == @options.exitOnBlur + + # If @options.exitOnClick is truthy, then the mode will exit on any click event. + if @options.exitOnClick + @push + _name: "mode-#{@id}/exitOnClick" + "click": (event) => @alwaysContinueBubbling => @exit event + + # Some modes are singletons: there may be at most one instance active at any time. A mode is a singleton + # if @options.singleton is truthy. The value of @options.singleton should be the key which is intended to + # be unique. New instances deactivate existing instances with the same key. + if @options.singleton + do => + singletons = Mode.singletons ||= {} + key = @options.singleton + @onExit => delete singletons[key] if singletons[key] == @ + if singletons[key] + @log "singleton:", "deactivating #{singletons[key].id}" + singletons[key].exit() + singletons[key] = @ + + # If @options.trackState is truthy, then the mode mainatins the current state in @enabled and @passKeys, + # and calls @registerStateChange() (if defined) whenever the state changes. The mode also tracks the + # current keyQueue in @keyQueue. + if @options.trackState + @enabled = false + @passKeys = "" + @keyQueue = "" + @push + _name: "mode-#{@id}/registerStateChange" + registerStateChange: ({ enabled: enabled, passKeys: passKeys }) => @alwaysContinueBubbling => + if enabled != @enabled or passKeys != @passKeys + @enabled = enabled + @passKeys = passKeys + @registerStateChange?() + registerKeyQueue: ({ keyQueue: keyQueue }) => @alwaysContinueBubbling => @keyQueue = keyQueue + + Mode.modes.push @ + Mode.updateBadge() + @logModes() + # End of Mode constructor. + + push: (handlers) -> + handlers._name ||= "mode-#{@id}" + @handlers.push handlerStack.push handlers + + unshift: (handlers) -> + handlers._name ||= "mode-#{@id}" + @handlers.push handlerStack.unshift handlers + + onExit: (handler) -> + @exitHandlers.push handler + + exit: -> + if @modeIsActive + @log "deactivate:", @id + handler() for handler in @exitHandlers + handlerStack.remove handlerId for handlerId in @handlers + Mode.modes = Mode.modes.filter (mode) => mode != @ + Mode.updateBadge() + @modeIsActive = false + + # The badge is chosen by bubbling an "updateBadge" event down the handler stack allowing each mode the + # opportunity to choose a badge. This is overridden in sub-classes. + updateBadge: (badge) -> + badge.badge ||= @badge + + # Shorthand for an otherwise long name. This wraps a handler with an arbitrary return value, and always + # yields @continueBubbling instead. This simplifies handlers if they always continue bubbling (a common + # case), because they do not need to be concerned with the value they yield. + alwaysContinueBubbling: handlerStack.alwaysContinueBubbling + + # Static method. Used externally and internally to initiate bubbling of an updateBadge event and to send + # the resulting badge to the background page. We only update the badge if this document (hence this frame) + # has the focus. + @updateBadge: -> + if document.hasFocus() + handlerStack.bubbleEvent "updateBadge", badge = badge: "" + chrome.runtime.sendMessage + handler: "setBadge" + badge: badge.badge + + # Debugging routines. + logModes: -> + if @debug + @log "active modes (top to bottom):" + @log " ", mode.id for mode in Mode.modes[..].reverse() + + log: (args...) -> + console.log args... if @debug + + # Return the must-recently activated mode (only used in tests). + @top: -> + @modes[@modes.length-1] + +# BadgeMode is a pseudo mode for triggering badge updates on focus changes and state updates. It sits at the +# bottom of the handler stack, and so it receives state changes *after* all other modes, and can override the +# badge choice of the other modes. We create the the one-and-only instance here. +new class BadgeMode extends Mode + constructor: () -> + super + name: "badge" + trackState: true + + # FIXME(smblott) BadgeMode is currently triggering an updateBadge event on every focus event. That's a + # lot, considerably more than necessary. Really, it only needs to trigger when we change frame, or when + # we change tab. + @push + _name: "mode-#{@id}/focus" + "focus": => @alwaysContinueBubbling -> Mode.updateBadge() + + updateBadge: (badge) -> + # If we're not enabled, then post an empty badge. + badge.badge = "" unless @enabled + + # When the registerStateChange event bubbles to the bottom of the stack, all modes have been notified. So + # it's now time to update the badge. + registerStateChange: -> + Mode.updateBadge() + +root = exports ? window +root.Mode = Mode diff --git a/content_scripts/mode_find.coffee b/content_scripts/mode_find.coffee new file mode 100644 index 00000000..dff63949 --- /dev/null +++ b/content_scripts/mode_find.coffee @@ -0,0 +1,66 @@ +# NOTE(smblott). Ultimately, all of the FindMode-related code should be moved here. + +# This prevents unmapped printable characters from being passed through to underlying page; see #1415. Only +# used by PostFindMode, below. +class SuppressPrintable extends Mode + constructor: (options) -> + super options + handler = (event) => if KeyboardUtils.isPrintable event then @suppressEvent else @continueBubbling + type = document.getSelection().type + + # We use unshift here, so we see events after normal mode, so we only see unmapped keys. + @unshift + _name: "mode-#{@id}/suppress-printable" + keydown: handler + keypress: handler + keyup: (event) => + # If the selection type has changed (usually, no longer "Range"), then the user is interacting with + # the input element, so we get out of the way. See discussion of option 5c from #1415. + if document.getSelection().type != type then @exit() else handler event + +# When we use find, the selection/focus can land in a focusable/editable element. In this situation, special +# considerations apply. We implement three special cases: +# 1. Disable insert mode, because the user hasn't asked to enter insert mode. We do this by using +# InsertMode.suppressEvent. +# 2. Prevent unmapped printable keyboard events from propagating to the page; see #1415. We do this by +# inheriting from SuppressPrintable. +# 3. If the very-next keystroke is Escape, then drop immediately into insert mode. +# +class PostFindMode extends SuppressPrintable + constructor: -> + return unless document.activeElement and DomUtils.isEditable document.activeElement + element = document.activeElement + + super + name: "post-find" + # We show a "?" badge, but only while an Escape activates insert mode. + badge: "?" + singleton: PostFindMode + exitOnBlur: element + exitOnClick: true + keydown: (event) -> InsertMode.suppressEvent event # Always truthy, so always continues bubbling. + keypress: (event) -> InsertMode.suppressEvent event + keyup: (event) -> InsertMode.suppressEvent event + + # If the very-next keydown is Escape, then exit immediately, thereby passing subsequent keys to the + # underlying insert-mode instance. + @push + _name: "mode-#{@id}/handle-escape" + keydown: (event) => + if KeyboardUtils.isEscape event + DomUtils.suppressKeyupAfterEscape handlerStack + @exit() + @suppressEvent + else + handlerStack.remove() + @badge = "" + Mode.updateBadge() + @continueBubbling + + updateBadge: (badge) -> + badge.badge ||= @badge + # Suppress the "I" badge from insert mode. + InsertMode.suppressEvent badge # Always truthy. + +root = exports ? window +root.PostFindMode = PostFindMode diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee new file mode 100644 index 00000000..eac4a3d0 --- /dev/null +++ b/content_scripts/mode_insert.coffee @@ -0,0 +1,84 @@ + +class InsertMode extends Mode + constructor: (options = {}) -> + # There is one permanently-installed instance of InsertMode. It tracks focus changes and + # activates/deactivates itself (by setting @insertModeLock) accordingly. + @permanent = options.permanent + + # If truthy, then we were activated by the user (with "i"). + @global = options.global + + handleKeyEvent = (event) => + return @continueBubbling unless @isActive event + return @stopBubblingAndTrue 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. + # NOTE(smblott, 2014/12/22) Including embeds for .blur() etc. here is experimental. It appears to be + # the right thing to do for most common use cases. However, it could also cripple flash-based sites and + # games. See discussion in #1211 and #1194. + target.blur() + @exit event, event.srcElement + @suppressEvent + + defaults = + name: "insert" + keypress: handleKeyEvent + keyup: handleKeyEvent + keydown: handleKeyEvent + + super extend defaults, options + + @insertModeLock = + if document.activeElement and DomUtils.isEditable document.activeElement + # An input element is already active, so use it. + document.activeElement + else + null + + @push + "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 + + # Only for tests. This gives us a hook to test the status of the permanent instance. + InsertMode.permanentInstance = @ 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 + Mode.updateBadge() + + exit: (_, target) -> + # Note: target == undefined, here, is required only for tests. + 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. + if @permanent then Mode.updateBadge() else super() + + updateBadge: (badge) -> + badge.badge ||= "I" if @isActive badge + + # Static stuff. This allows PostFindMode to suppress the permanently-installed InsertMode instance. + @suppressedEvent: null + @suppressEvent: (event) -> @suppressedEvent = event + +root = exports ? window +root.InsertMode = InsertMode diff --git a/content_scripts/mode_passkeys.coffee b/content_scripts/mode_passkeys.coffee new file mode 100644 index 00000000..64db5447 --- /dev/null +++ b/content_scripts/mode_passkeys.coffee @@ -0,0 +1,24 @@ + +class PassKeysMode extends Mode + constructor: -> + super + name: "passkeys" + trackState: true # Maintain @enabled, @passKeys and @keyQueue. + keydown: (event) => @handleKeyChar KeyboardUtils.getKeyChar event + keypress: (event) => @handleKeyChar String.fromCharCode event.charCode + keyup: (event) => @handleKeyChar KeyboardUtils.getKeyChar event + + # Keystrokes are *never* considered passKeys if the keyQueue is not empty. So, for example, if 't' is a + # passKey, then 'gt' and '99t' will neverthless be handled by Vimium. + handleKeyChar: (keyChar) -> + if keyChar and not @keyQueue and 0 <= @passKeys.indexOf keyChar + @stopBubblingAndTrue + else + @continueBubbling + + # Disabled, pending experimentation with how/whether to use badges (smblott, 2015/01/17). + # updateBadge: (badge) -> + # badge.badge ||= "P" if @passKeys and not @keyQueue + +root = exports ? window +root.PassKeysMode = PassKeysMode diff --git a/content_scripts/mode_visual.coffee b/content_scripts/mode_visual.coffee new file mode 100644 index 00000000..2580106d --- /dev/null +++ b/content_scripts/mode_visual.coffee @@ -0,0 +1,20 @@ + +class VisualMode extends Mode + constructor: (element=null) -> + super + name: "visual" + badge: "V" + exitOnEscape: true + exitOnBlur: element + + keydown: (event) => + return @suppressEvent + + keypress: (event) => + return @suppressEvent + + keyup: (event) => + return @suppressEvent + +root = exports ? window +root.VisualMode = VisualMode diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index b3a14c78..6e2e1ffc 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -1,94 +1,246 @@ -window.Scroller = root = {} - # # activatedElement is different from document.activeElement -- the latter seems to be reserved mostly for # input elements. This mechanism allows us to decide whether to scroll a div or to scroll the whole document. # activatedElement = null -root.init = -> - handlerStack.push DOMActivate: -> activatedElement = event.target +# Return 0, -1 or 1: the sign of the argument. +# NOTE(smblott; 2014/12/17) We would like to use Math.sign(). However, according to this site +# (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sign) Math.sign() was +# only introduced in Chrome 38. This caused problems in R1.48 for users with old Chrome installations. We +# can replace this with Math.sign() at some point. +getSign = (val) -> + if not val + 0 + else + if val < 0 then -1 else 1 scrollProperties = x: { axisName: 'scrollLeft' max: 'scrollWidth' - viewSize: 'clientHeight' + viewSize: 'clientWidth' } y: { axisName: 'scrollTop' max: 'scrollHeight' - viewSize: 'clientWidth' + viewSize: 'clientHeight' } -getDimension = (el, direction, name) -> - # the clientSizes of the body are the dimensions of the entire page, but the viewport should only be the - # part visible through the window - if name is 'viewSize' and el is document.body - if direction is 'x' then window.innerWidth else window.innerHeight +# Translate a scroll request into a number (which will be interpreted by `scrollBy` as a relative amount, or +# by `scrollTo` as an absolute amount). :direction must be "x" or "y". :amount may be either a number (in +# which case it is simply returned) or a string. If :amount is a string, then it is either "max" (meaning the +# height or width of element), or "viewSize". In both cases, we look up and return the requested amount, +# either in `element` or in `window`, as appropriate. +getDimension = (el, direction, amount) -> + if Utils.isString amount + name = amount + # the clientSizes of the body are the dimensions of the entire page, but the viewport should only be the + # part visible through the window + if name is 'viewSize' and el is document.body + # TODO(smblott) Should we not be returning the width/height of element, here? + if direction is 'x' then window.innerWidth else window.innerHeight + else + el[scrollProperties[direction][name]] else - el[scrollProperties[direction][name]] + amount -# Chrome does not report scrollHeight accurately for nodes with pseudo-elements of height 0 (bug 110149). -# Therefore we cannot figure out if we have scrolled to the bottom of an element by testing if scrollTop + -# clientHeight == scrollHeight. So just try to increase scrollTop blindly -- if it fails we know we have -# reached the end of the content. -ensureScrollChange = (direction, changeFn) -> +# Perform a scroll. Return true if we successfully scrolled by any amount, and false otherwise. +performScroll = (element, direction, amount) -> axisName = scrollProperties[direction].axisName - element = activatedElement - loop - oldScrollValue = element[axisName] - # Elements with `overflow: hidden` should not be scrolled. - overflow = window.getComputedStyle(element).getPropertyValue("overflow-#{direction}") - changeFn(element, axisName) unless overflow == "hidden" - break unless element[axisName] == oldScrollValue && element != document.body - # we may have an orphaned element. if so, just scroll the body element. - element = element.parentElement || document.body - - # if the activated element has been scrolled completely offscreen, subsequent changes in its scroll - # position will not provide any more visual feedback to the user. therefore we deactivate it so that - # subsequent scrolls only move the parent element. + before = element[axisName] + element[axisName] += amount + element[axisName] != before + +# Test whether `element` should be scrolled. E.g. hidden elements should not be scrolled. +shouldScroll = (element, direction) -> + computedStyle = window.getComputedStyle(element) + # Elements with `overflow: hidden` must not be scrolled. + return false if computedStyle.getPropertyValue("overflow-#{direction}") == "hidden" + # Elements which are not visible should not be scrolled. + return false if computedStyle.getPropertyValue("visibility") in ["hidden", "collapse"] + return false if computedStyle.getPropertyValue("display") == "none" + true + +# Test whether element does actually scroll in the direction required when asked to do so. Due to chrome bug +# 110149, scrollHeight and clientHeight cannot be used to reliably determine whether an element will scroll. +# Instead, we scroll the element by 1 or -1 and see if it moved (then put it back). :factor is the factor by +# which :scrollBy and :scrollTo will later scale the scroll amount. :factor can be negative, so we need it +# here in order to decide whether we should test a forward scroll or a backward scroll. +# Bug last verified in Chrome 38.0.2125.104. +doesScroll = (element, direction, amount, factor) -> + # amount is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls (only + # gg, G, and friends), amount can be either a string ("max" or "viewSize") or zero. In the former case, + # we're definitely scrolling forwards, so any positive value will do for delta. In the latter, we're + # definitely scrolling backwards, so a delta of -1 will do. For absolute scrolls, factor is always 1. + delta = factor * getDimension(element, direction, amount) || -1 + delta = getSign delta # 1 or -1 + performScroll(element, direction, delta) and performScroll(element, direction, -delta) + +# From element and its parents, find the first which we should scroll and which does scroll. +findScrollableElement = (element, direction, amount, factor) -> + while element != document.body and + not (doesScroll(element, direction, amount, factor) and shouldScroll(element, direction)) + element = element.parentElement || document.body + element + +# On some pages, document.body is not 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=document.body) -> + if doesScroll(element, "y", 1, 1) or doesScroll(element, "y", -1, 1) + element + else + children = ({element: child, rect: DomUtils.getVisibleClientRect(child)} for child in element.children) + children = children.filter (child) -> child.rect # Filter out non-visible elements. + children.map (child) -> child.area = child.rect.width * child.rect.height + for child in children.sort((a,b) -> b.area - a.area) # Largest to smallest by visible area. + return ele if ele = firstScrollableElement child.element + null + +checkVisibility = (element) -> + # If the activated element has been scrolled completely offscreen, then subsequent changes in its scroll + # position will not provide any more visual feedback to the user. Therefore, we deactivate it so that + # subsequent scrolls affect the parent element. rect = activatedElement.getBoundingClientRect() if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) activatedElement = element -# scroll the active element in :direction by :amount * :factor. -# :factor is needed because :amount can take on string values, which scrollBy converts to element dimensions. -root.scrollBy = (direction, amount, factor = 1) -> - # if this is called before domReady, just use the window scroll function - if (!document.body and amount instanceof Number) - if (direction == "x") - window.scrollBy(amount, 0) - else - window.scrollBy(0, amount) - return +# How scrolling is handled by CoreScroller. +# - For jump scrolling, the entire scroll happens immediately. +# - For smooth scrolling with distinct key presses, a separate animator is initiated for each key press. +# Therefore, several animators may be active at the same time. This ensures that two quick taps on `j` +# scroll to the same position as two slower taps. +# - For smooth scrolling with keyboard repeat (continuous scrolling), the most recently-activated animator +# continues scrolling at least until its keyup event is received. We never initiate a new animator on +# keyboard repeat. - if (!activatedElement || !isRendered(activatedElement)) - activatedElement = document.body +# CoreScroller contains the core function (scroll) and logic for relative scrolls. All scrolls are ultimately +# translated to relative scrolls. CoreScroller is not exported. +CoreScroller = + init: (frontendSettings) -> + @settings = frontendSettings + @time = 0 + @lastEvent = null + @keyIsDown = false - ensureScrollChange direction, (element, axisName) -> - if Utils.isString amount - elementAmount = getDimension element, direction, amount - else - elementAmount = amount - elementAmount *= factor - element[axisName] += elementAmount + handlerStack.push + _name: 'scroller/track-key-status' + keydown: (event) => + handlerStack.alwaysContinueBubbling => + @keyIsDown = true + @lastEvent = event + keyup: => + handlerStack.alwaysContinueBubbling => + @keyIsDown = false + @time += 1 -root.scrollTo = (direction, pos) -> - return unless document.body + # Return true if CoreScroller would not initiate a new scroll right now. + wouldNotInitiateScroll: -> @lastEvent?.repeat and @settings.get "smoothScroll" - if (!activatedElement || !isRendered(activatedElement)) - activatedElement = document.body + # Calibration fudge factors for continuous scrolling. The calibration value starts at 1.0. We then + # increase it (until it exceeds @maxCalibration) if we guess that the scroll is too slow, or decrease it + # (until it is less than @minCalibration) if we guess that the scroll is too fast. The cutoff point for + # which guess we make is @calibrationBoundary. We require: 0 < @minCalibration <= 1 <= @maxCalibration. + minCalibration: 0.5 # Controls how much we're willing to slow scrolls down; smaller means more slow down. + maxCalibration: 1.6 # Controls how much we're willing to speed scrolls up; bigger means more speed up. + calibrationBoundary: 150 # Boundary between scrolls which are considered too slow, or too fast. - ensureScrollChange direction, (element, axisName) -> - if Utils.isString pos - elementPos = getDimension element, direction, pos - else - elementPos = pos - element[axisName] = elementPos - -# TODO refactor and put this together with the code in getVisibleClientRect -isRendered = (element) -> - computedStyle = window.getComputedStyle(element, null) - return !(computedStyle.getPropertyValue("visibility") != "visible" || - computedStyle.getPropertyValue("display") == "none") + # Scroll element by a relative amount (a number) in some direction. + scroll: (element, direction, amount) -> + return unless amount + + unless @settings.get "smoothScroll" + # Jump scrolling. + performScroll element, direction, amount + checkVisibility element + return + + # We don't activate new animators on keyboard repeats; rather, the most-recently activated animator + # continues scrolling. + return if @lastEvent?.repeat + + activationTime = ++@time + myKeyIsStillDown = => @time == activationTime and @keyIsDown + + # Store amount's sign and make amount positive; the arithmetic is clearer when amount is positive. + sign = getSign amount + amount = Math.abs amount + + # Initial intended scroll duration (in ms). We allow a bit longer for longer scrolls. + duration = Math.max 100, 20 * Math.log amount + + totalDelta = 0 + totalElapsed = 0.0 + calibration = 1.0 + previousTimestamp = null + + animate = (timestamp) => + previousTimestamp ?= timestamp + return requestAnimationFrame(animate) if timestamp == previousTimestamp + + # The elapsed time is typically about 16ms. + elapsed = timestamp - previousTimestamp + totalElapsed += elapsed + previousTimestamp = timestamp + + # The constants in the duration calculation, above, are chosen to provide reasonable scroll speeds for + # distinct keypresses. For continuous scrolls, some scrolls are too slow, and others too fast. Here, we + # speed up the slower scrolls, and slow down the faster scrolls. + if myKeyIsStillDown() and 75 <= totalElapsed and @minCalibration <= calibration <= @maxCalibration + calibration *= 1.05 if 1.05 * calibration * amount < @calibrationBoundary # Speed up slow scrolls. + calibration *= 0.95 if @calibrationBoundary < 0.95 * calibration * amount # Slow down fast scrolls. + + # Calculate the initial delta, rounding up to ensure progress. Then, adjust delta to account for the + # current scroll state. + delta = Math.ceil amount * (elapsed / duration) * calibration + delta = if myKeyIsStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta + + if delta and performScroll element, direction, sign * delta + totalDelta += delta + requestAnimationFrame animate + else + # We're done. + checkVisibility element + + # Launch animator. + requestAnimationFrame animate + +# Scroller contains the two main scroll functions (scrollBy and scrollTo) which are exported to clients. +Scroller = + init: (frontendSettings) -> + handlerStack.push + _name: 'scroller/active-element' + DOMActivate: (event) -> handlerStack.alwaysContinueBubbling -> activatedElement = event.target + CoreScroller.init frontendSettings + + # scroll the active element in :direction by :amount * :factor. + # :factor is needed because :amount can take on string values, which scrollBy converts to element dimensions. + scrollBy: (direction, amount, factor = 1) -> + # if this is called before domReady, just use the window scroll function + if (!document.body and amount instanceof Number) + if (direction == "x") + window.scrollBy(amount, 0) + else + window.scrollBy(0, amount) + return + + activatedElement ||= document.body and firstScrollableElement() + return unless activatedElement + + # Avoid the expensive scroll calculation if it will not be used. This reduces costs during smooth, + # continuous scrolls, and is just an optimization. + unless CoreScroller.wouldNotInitiateScroll() + element = findScrollableElement activatedElement, direction, amount, factor + elementAmount = factor * getDimension element, direction, amount + CoreScroller.scroll element, direction, elementAmount + + scrollTo: (direction, pos) -> + activatedElement ||= document.body and firstScrollableElement() + return unless activatedElement + + element = findScrollableElement activatedElement, direction, pos, 1 + amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] + CoreScroller.scroll element, direction, amount + +root = exports ? window +root.Scroller = Scroller diff --git a/content_scripts/ui_component.coffee b/content_scripts/ui_component.coffee new file mode 100644 index 00000000..c4ed3bf6 --- /dev/null +++ b/content_scripts/ui_component.coffee @@ -0,0 +1,54 @@ +class UIComponent + iframeElement: null + iframePort: null + showing: null + + constructor: (iframeUrl, className, @handleMessage) -> + @iframeElement = document.createElement "iframe" + @iframeElement.className = className + @iframeElement.seamless = "seamless" + @iframeElement.src = chrome.runtime.getURL iframeUrl + @iframeElement.addEventListener "load", => @openPort() + document.documentElement.appendChild @iframeElement + @showing = true # The iframe is visible now. + # Hide the iframe, but don't interfere with the focus. + @hide false + + # Open a port and pass it to the iframe via window.postMessage. + openPort: -> + messageChannel = new MessageChannel() + @iframePort = messageChannel.port1 + @iframePort.onmessage = (event) => @handleMessage event + + # Get vimiumSecret so the iframe can determine that our message isn't the page impersonating us. + chrome.storage.local.get "vimiumSecret", ({vimiumSecret: secret}) => + @iframeElement.contentWindow.postMessage secret, chrome.runtime.getURL(""), [messageChannel.port2] + + postMessage: (message) -> + @iframePort.postMessage message + + activate: (message) -> + @postMessage message if message? + if @showing + # NOTE(smblott) Experimental. Not sure this is a great idea. If the iframe was already showing, then + # the user gets no visual feedback when it is re-focused. So flash its border. + @iframeElement.classList.add "vimiumUIComponentReactivated" + setTimeout((=> @iframeElement.classList.remove "vimiumUIComponentReactivated"), 200) + else + @show() + @iframeElement.focus() + + show: (message) -> + @postMessage message if message? + @iframeElement.classList.remove "vimiumUIComponentHidden" + @iframeElement.classList.add "vimiumUIComponentShowing" + @showing = true + + hide: (focusWindow = true)-> + @iframeElement.classList.remove "vimiumUIComponentShowing" + @iframeElement.classList.add "vimiumUIComponentHidden" + window.focus() if focusWindow + @showing = false + +root = exports ? window +root.UIComponent = UIComponent diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css index 9e25c59e..fb8824c2 100644 --- a/content_scripts/vimium.css +++ b/content_scripts/vimium.css @@ -2,6 +2,10 @@ * Many CSS class names in this file use the verbose "vimiumXYZ" as the class name. This is so we don't * use the same CSS class names that the page is using, so the page's CSS doesn't mess with the style of our * Vimium dialogs. + * + * The z-indexes of Vimium elements are very large, because we always want them to show on top. Chrome may + * support up to Number.MAX_VALUE, which is approximately 1.7976e+308. We're using 2^31, which is the max + * value of a singed 32 bit int. Let's try larger valeus if 2**31 empirically isn't large enough. */ /* @@ -54,7 +58,7 @@ tr.vimiumReset { vertical-align: baseline; white-space: normal; width: auto; - z-index: 99999999; + z-index: 2147483648; } /* Linkhints CSS */ @@ -93,6 +97,7 @@ div.internalVimiumInputHint { display: block; background-color: rgba(255, 247, 133, 0.3); border: solid 1px #C38A22; + pointer-events: none; } div.internalVimiumSelectedInputHint { @@ -122,7 +127,8 @@ div#vimiumHelpDialog { top:50px; -webkit-box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 6px; overflow-y: auto; - z-index:99999998; + /* One less than vimiumReset */ + z-index: 2147483647; } div#vimiumHelpDialog a { color:blue; } @@ -231,8 +237,8 @@ div.vimiumHUD { border-radius: 4px 4px 0 0; font-family: "Lucida Grande", "Arial", "Sans"; font-size: 12px; - /* One less than vimium's hint markers, so link hints can be shown e.g. for the panel's close button. */ - z-index: 99999997; + /* One less than vimium's hint markers, so link hints can be shown e.g. for the HUD panel's close button. */ + z-index: 2147483646; text-shadow: 0px 1px 2px #FFF; line-height: 1.0; opacity: 0; @@ -264,140 +270,47 @@ div.vimiumHUD a.close-button:hover { body.vimiumFindMode ::selection { background: #ff9632; -}; +} -/* Vomnibar CSS */ +/* Vomnibar Frame CSS */ -#vomnibar ol, #vomnibar ul { - list-style: none; - display: block; -} +iframe.vomnibarFrame { + background-color: transparent; + padding: 0px; + overflow: hidden; -#vomnibar { display: block; position: fixed; - width: 80%; + width: calc(80% + 20px); /* same adjustment as in pages/vomnibar.coffee */ min-width: 400px; + height: calc(100% - 70px); top: 70px; left: 50%; margin: 0 0 0 -40%; + border: none; font-family: sans-serif; - background: #F1F1F1; - text-align: left; - border-radius: 4px; - box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8); - border: 1px solid #aaa; /* One less than hint markers and the help dialog. */ - z-index: 99999996; -} - -#vomnibar input { - color: #000; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-size: 20px; - height: 34px; - margin-bottom: 0; - padding: 4px; - background-color: white; - border-radius: 3px; - border: 1px solid #E8E8E8; - box-shadow: #444 0px 0px 1px; - width: 100%; - outline: none; - box-sizing: border-box; -} - -#vomnibar .vomnibarSearchArea { - display: block; - padding: 10px; - background-color: #F1F1F1; - border-radius: 4px 4px 0 0; - border-bottom: 1px solid #C6C9CE; -} - -#vomnibar ul { - background-color: white; - border-radius: 0 0 4px 4px; - list-style: none; - padding: 10px 0; - padding-top: 0; -} - -#vomnibar li { - border-bottom: 1px solid #ddd; - line-height: 1.1em; - padding: 7px 10px; - font-size: 16px; - color: black; - position: relative; - display: list-item; - margin: auto; -} - -#vomnibar li:last-of-type { - border-bottom: none; -} - -#vomnibar li .vomnibarTopHalf, #vomnibar li .vomnibarBottomHalf { - display: block; - overflow: hidden; -} - -#vomnibar li .vomnibarBottomHalf { - font-size: 15px; - margin-top: 3px; - padding: 2px 0; + z-index: 2147483645; } -#vomnibar li .vomnibarSource { - color: #777; - margin-right: 4px; -} -#vomnibar li .vomnibarRelevancy { +div#vimiumFlash { + box-shadow: 0px 0px 4px 2px #4183C4; + padding: 1px; + background-color: transparent; position: absolute; - right: 0; - top: 0; - padding: 5px; - background-color: white; - color: black; - font-family: monospace; - width: 100px; - overflow: hidden; -} - -#vomnibar li .vomnibarUrl { - white-space: nowrap; - color: #224684; -} - -#vomnibar li .vomnibarMatch { - font-weight: bold; - color: black; + z-index: 2147483648; } -#vomnibar li em, #vomnibar li .vomnibarTitle { - color: black; - margin-left: 4px; - font-weight: normal; -} -#vomnibar li em { font-style: italic; } -#vomnibar li em .vomnibarMatch, #vomnibar li .vomnibarTitle .vomnibarMatch { - color: #333; - text-decoration: underline; +/* UIComponent CSS */ +iframe.vimiumUIComponentHidden { + display: none; } -#vomnibar li.vomnibarSelected { - background-color: #BBCEE9; - font-weight: normal; +iframe.vimiumUIComponentVisible { + display: block; } - - -div#vimiumFlash { - box-shadow: 0px 0px 4px 2px #4183C4; - padding: 1px; - background-color: transparent; - position: absolute; - z-index: 99999; +iframe.vimiumUIComponentReactivated { + border: 5px solid yellow; } diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index d5586bd8..725d8a53 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -4,13 +4,13 @@ # background page that we're in domReady and ready to accept normal commands by connectiong to a port named # "domReady". # -window.handlerStack = new HandlerStack -insertModeLock = null +targetElement = null findMode = false findModeQuery = { rawQuery: "", matchCount: 0 } findModeQueryHasResults = false findModeAnchorNode = null +findModeInitialRange = null isShowingHelpDialog = false keyPort = null # Users can disable Vimium on URL patterns via the settings page. The following two variables @@ -21,8 +21,8 @@ isEnabledForUrl = true passKeys = null keyQueue = null # The user's operating system. -currentCompletionKeys = null -validFirstKeys = null +currentCompletionKeys = "" +validFirstKeys = "" # 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 @@ -49,7 +49,7 @@ settings = loadedValues: 0 valuesToLoad: ["scrollStepSize", "linkHintCharacters", "linkHintNumbers", "filterLinkHints", "hideHud", "previousPatterns", "nextPatterns", "findModeRawQuery", "regexFindMode", "userDefinedLinkHintCss", - "helpDialog_showAdvancedCommands"] + "helpDialog_showAdvancedCommands", "smoothScroll"] isLoaded: false eventListeners: {} @@ -57,6 +57,15 @@ settings = @port = chrome.runtime.connect({ name: "settings" }) @port.onMessage.addListener(@receiveMessage) + # If the port is closed, the background page has gone away (since we never close it ourselves). Stub the + # settings object so we don't keep trying to connect to the extension even though it's gone away. + @port.onDisconnect.addListener => + @port = null + for own property, value of this + # @get doesn't depend on @port, so we can continue to support it to try and reduce errors. + @[property] = (->) if "function" == typeof value and property != "get" + + get: (key) -> @values[key] set: (key, value) -> @@ -101,7 +110,21 @@ initializePreDomReady = -> settings.addEventListener("load", LinkHints.init.bind(LinkHints)) settings.load() - Scroller.init() + class NormalMode extends Mode + constructor: -> + super + name: "normal" + keydown: (event) => onKeydown.call @, event + keypress: (event) => onKeypress.call @, event + keyup: (event) => onKeyup.call @, event + + Scroller.init settings + + # Install the permanent modes. The permanently-installed insert mode tracks focus/blur events, and + # activates/deactivates itself accordingly. + new NormalMode + new PassKeysMode + new InsertMode permanent: true checkIfEnabledForUrl() @@ -109,6 +132,13 @@ initializePreDomReady = -> # Send the key to the key handler in the background page. keyPort = chrome.runtime.connect({ name: "keyDown" }) + # If the port is closed, the background page has gone away (since we never close it ourselves). Disable all + # our event listeners, and stub out chrome.runtime.sendMessage/connect (to prevent errors). + # TODO(mrmr1993): Do some actual cleanup to free resources, hide UI, etc. + keyPort.onDisconnect.addListener -> + isEnabledForUrl = false + chrome.runtime.sendMessage = -> + chrome.runtime.connect = -> requestHandlers = hideUpgradeNotification: -> HUD.hideUpgradeNotification() @@ -120,9 +150,11 @@ initializePreDomReady = -> getScrollPosition: -> scrollX: window.scrollX, scrollY: window.scrollY setScrollPosition: (request) -> setScrollPosition request.scrollX, request.scrollY executePageCommand: executePageCommand - getActiveState: -> { enabled: isEnabledForUrl, passKeys: passKeys } + getActiveState: getActiveState setState: setState - currentKeyQueue: (request) -> keyQueue = request.keyQueue + currentKeyQueue: (request) -> + keyQueue = request.keyQueue + handlerStack.bubbleEvent "registerKeyQueue", { keyQueue: keyQueue } chrome.runtime.onMessage.addListener (request, sender, sendResponse) -> # In the options page, we will receive requests from both content and background scripts. ignore those @@ -153,11 +185,8 @@ initializeWhenEnabled = (newPassKeys) -> if (!installedListeners) # Key event handlers fire on window before they do on document. Prefer window for key events so the page # can't set handlers to grab the keys before us. - installListener window, "keydown", onKeydown - installListener window, "keypress", onKeypress - installListener window, "keyup", onKeyup - installListener document, "focus", onFocusCapturePhase - installListener document, "blur", onBlurCapturePhase + for type in ["keydown", "keypress", "keyup", "click", "focus", "blur"] + do (type) -> installListener window, type, (event) -> handlerStack.bubbleEvent type, event installListener document, "DOMActivate", onDOMActivate enterInsertModeIfElementIsFocused() installedListeners = true @@ -166,6 +195,13 @@ setState = (request) -> initializeWhenEnabled(request.passKeys) if request.enabled isEnabledForUrl = request.enabled passKeys = request.passKeys + handlerStack.bubbleEvent "registerStateChange", + enabled: request.enabled + passKeys: request.passKeys + +getActiveState = -> + Mode.updateBadge() + return { enabled: isEnabledForUrl, passKeys: passKeys } # # The backend needs to know which frame has focus. @@ -183,10 +219,12 @@ initializeOnDomReady = -> # Tell the background page we're in the dom ready state. chrome.runtime.connect({ name: "domReady" }) + CursorHider.init() + Vomnibar.init() registerFrame = -> # Don't register frameset containers; focusing them is no use. - if document.body.tagName != "FRAMESET" + unless document.body?.tagName.toLowerCase() == "frameset" chrome.runtime.sendMessage handler: "registerFrame" frameId: frameId @@ -225,6 +263,12 @@ setScrollPosition = (scrollX, scrollY) -> # Called from the backend in order to change frame focus. # window.focusThisFrame = (shouldHighlight) -> + if window.innerWidth < 3 or window.innerHeight < 3 + # This frame is too small to focus. Cancel and tell the background frame to focus the next one instead. + # This affects sites like Google Inbox, which have many tiny iframes. See #1317. + # Here we're assuming that there is at least one frame large enough to focus. + chrome.runtime.sendMessage({ handler: "nextFrame", frameId: frameId }) + return window.focus() if (document.body && shouldHighlight) borderWas = document.body.style.border @@ -281,68 +325,117 @@ extend window, HUD.showForDuration("Yanked URL", 1000) - 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) - rect = DomUtils.getVisibleClientRect(element) - continue if rect == null - { element: element, rect: rect } - - return if visibleInputs.length == 0 - - selectedInputIndex = Math.min(count - 1, visibleInputs.length - 1) - - visibleInputs[selectedInputIndex].element.focus() - - return if visibleInputs.length == 1 - - hints = for tuple in visibleInputs - hint = document.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 - - hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' - - hintContainingDiv = DomUtils.addElementList(hints, - { id: "vimiumInputMarkerContainer", className: "vimiumReset" }) - - handlerStack.push keydown: (event) -> - if event.keyCode == KeyboardUtils.keyCodes.tab - hints[selectedInputIndex].classList.remove 'internalVimiumSelectedInputHint' - if event.shiftKey - if --selectedInputIndex == -1 - selectedInputIndex = hints.length - 1 + enterInsertMode: -> + new InsertMode global: true + + enterVisualMode: => + new VisualMode() + + focusInput: do -> + # Track the most recently focused input element. + recentlyFocusedElement = null + handlerStack.push + _name: "focus-input-tracker" + focus: (event) -> + recentlyFocusedElement = event.target if DomUtils.isEditable event.target + true + + (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 + rect = DomUtils.getVisibleClientRect element + continue if rect == null + { element: element, rect: rect } + + return if visibleInputs.length == 0 + + 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 - if ++selectedInputIndex == hints.length - selectedInputIndex = 0 - hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' - visibleInputs[selectedInputIndex].element.focus() - else unless event.keyCode == KeyboardUtils.keyCodes.shiftKey - DomUtils.removeElement hintContainingDiv - @remove() - return true - - false + Math.min(count, visibleInputs.length) - 1 + + hints = for tuple in visibleInputs + hint = document.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" + badge: "?" + # We share a singleton with PostFindMode. That way, a new FocusSelector displaces any existing + # PostFindMode. + singleton: PostFindMode + 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' + visibleInputs[selectedInputIndex].element.focus() + @suppressEvent + else unless event.keyCode == KeyboardUtils.keyCodes.shiftKey + @exit() + @continueBubbling + + @onExit -> DomUtils.removeElement hintContainingDiv + hintContainingDiv = DomUtils.addElementList hints, + id: "vimiumInputMarkerContainer" + className: "vimiumReset" + + visibleInputs[selectedInputIndex].element.focus() + if visibleInputs.length == 1 + @exit() + else + hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' # Decide whether this keyChar should be passed to the underlying page. # Keystrokes are *never* considered passKeys if the keyQueue is not empty. So, for example, if 't' is a # passKey, then 'gt' and '99t' will neverthless be handled by vimium. isPassKey = ( keyChar ) -> + return false # Disabled. return !keyQueue and passKeys and 0 <= passKeys.indexOf(keyChar) -handledKeydownEvents = [] +# Track which keydown events we have handled, so that we can subsequently suppress the corresponding keyup +# event. +KeydownEvents = + handledEvents: {} + + stringify: (event) -> + JSON.stringify + metaKey: event.metaKey + altKey: event.altKey + ctrlKey: event.ctrlKey + keyIdentifier: event.keyIdentifier + keyCode: event.keyCode + + push: (event) -> + @handledEvents[@stringify event] = true + + # Yields truthy or falsy depending upon whether a corresponding keydown event is present (and removes that + # event). + pop: (event) -> + detailString = @stringify event + value = @handledEvents[detailString] + delete @handledEvents[detailString] + value # # Sends everything except i & ESC to the handler in background_page. i & ESC are special because they control @@ -351,9 +444,8 @@ handledKeydownEvents = [] # # Note that some keys will only register keydown events and not keystroke events, e.g. ESC. # +# @/this, here, is the the normal-mode Mode object. onKeypress = (event) -> - return unless handlerStack.bubbleEvent('keypress', event) - keyChar = "" # Ignore modifier keys by themselves. @@ -363,23 +455,27 @@ onKeypress = (event) -> # Enter insert mode when the user enables the native find interface. if (keyChar == "f" && KeyboardUtils.isPrimaryModifierKey(event)) enterInsertModeWithoutShowingIndicator() - return + return @stopBubblingAndTrue if (keyChar) if (findMode) handleKeyCharForFindMode(keyChar) DomUtils.suppressEvent(event) + return @stopBubblingAndTrue else if (!isInsertMode() && !findMode) if (isPassKey keyChar) - return undefined - if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar)) + return @stopBubblingAndTrue + if currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar) DomUtils.suppressEvent(event) + keyPort.postMessage({ keyChar:keyChar, frameId:frameId }) + return @stopBubblingAndTrue keyPort.postMessage({ keyChar:keyChar, frameId:frameId }) -onKeydown = (event) -> - return unless handlerStack.bubbleEvent('keydown', event) + return @continueBubbling +# @/this, here, is the the normal-mode Mode object. +onKeydown = (event) -> keyChar = "" # handle special keys, and normal input keys with modifiers being pressed. don't handle shiftKey alone (to @@ -408,46 +504,55 @@ onKeydown = (event) -> keyChar = "<" + keyChar + ">" if (isInsertMode() && KeyboardUtils.isEscape(event)) - # Note that we can't programmatically blur out of Flash embeds from Javascript. - if (!isEmbed(event.srcElement)) + if isEditable(event.srcElement) or isEmbed(event.srcElement) # Remove focus so the user can't just get himself back into insert mode by typing in the same input # box. - if (isEditable(event.srcElement)) - event.srcElement.blur() - exitInsertMode() - DomUtils.suppressEvent event - handledKeydownEvents.push event + # NOTE(smblott, 2014/12/22) Including embeds for .blur() etc. here is experimental. It appears to be + # the right thing to do for most common use cases. However, it could also cripple flash-based sites and + # games. See discussion in #1211 and #1194. + event.srcElement.blur() + exitInsertMode() + DomUtils.suppressEvent event + KeydownEvents.push event + return @stopBubblingAndTrue else if (findMode) if (KeyboardUtils.isEscape(event)) handleEscapeForFindMode() DomUtils.suppressEvent event - handledKeydownEvents.push event + KeydownEvents.push event + return @stopBubblingAndTrue else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey) handleDeleteForFindMode() DomUtils.suppressEvent event - handledKeydownEvents.push event + KeydownEvents.push event + return @stopBubblingAndTrue else if (event.keyCode == keyCodes.enter) handleEnterForFindMode() DomUtils.suppressEvent event - handledKeydownEvents.push event + KeydownEvents.push event + return @stopBubblingAndTrue else if (!modifiers) DomUtils.suppressPropagation(event) - handledKeydownEvents.push event + KeydownEvents.push event + return @stopBubblingAndTrue else if (isShowingHelpDialog && KeyboardUtils.isEscape(event)) hideHelpDialog() DomUtils.suppressEvent event - handledKeydownEvents.push event + KeydownEvents.push event + return @stopBubblingAndTrue else if (!isInsertMode() && !findMode) if (keyChar) if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar)) DomUtils.suppressEvent event - handledKeydownEvents.push event + KeydownEvents.push event + keyPort.postMessage({ keyChar:keyChar, frameId:frameId }) + return @stopBubblingAndTrue keyPort.postMessage({ keyChar:keyChar, frameId:frameId }) @@ -468,23 +573,16 @@ onKeydown = (event) -> (currentCompletionKeys.indexOf(KeyboardUtils.getKeyChar(event)) != -1 || isValidFirstKey(KeyboardUtils.getKeyChar(event)))) DomUtils.suppressPropagation(event) - handledKeydownEvents.push event + KeydownEvents.push event + return @stopBubblingAndTrue + return @continueBubbling + +# @/this, here, is the the normal-mode Mode object. onKeyup = (event) -> - return unless handlerStack.bubbleEvent("keyup", event) - return if isInsertMode() - - # Don't propagate the keyup to the underlying page if Vimium has handled it. See #733. - for keydown, i in handledKeydownEvents - if event.metaKey == keydown.metaKey and - event.altKey == keydown.altKey and - event.ctrlKey == keydown.ctrlKey and - event.keyIdentifier == keydown.keyIdentifier and - event.keyCode == keydown.keyCode - - handledKeydownEvents.splice i, 1 - DomUtils.suppressPropagation(event) - break + return @continueBubbling unless KeydownEvents.pop event + DomUtils.suppressPropagation(event) + @stopBubblingAndTrue checkIfEnabledForUrl = -> url = window.location.toString() @@ -496,8 +594,12 @@ checkIfEnabledForUrl = -> else if (HUD.isReady()) # Quickly hide any HUD we might already be showing, e.g. if we entered insert mode on page load. HUD.hide() + handlerStack.bubbleEvent "registerStateChange", + enabled: response.isEnabledForUrl + passKeys: response.passKeys -refreshCompletionKeys = (response) -> +# Exported to window, but only for DOM tests. +window.refreshCompletionKeys = (response) -> if (response) currentCompletionKeys = response.completionKeys @@ -545,35 +647,21 @@ isEditable = (target) -> focusableElements.indexOf(nodeName) >= 0 # -# Enters insert mode and show an "Insert mode" message. Showing the UI is only useful when entering insert -# mode manually by pressing "i". In most cases we do not show any UI (enterInsertModeWithoutShowingIndicator) -# -window.enterInsertMode = (target) -> - enterInsertModeWithoutShowingIndicator(target) - HUD.show("Insert mode") - -# # We cannot count on 'focus' and 'blur' events to happen sequentially. For example, if blurring element A # causes element B to come into focus, we may get "B focus" before "A blur". Thus we only leave insert mode -# when the last editable element that came into focus -- which insertModeLock points to -- has been blurred. -# If insert mode is entered manually (via pressing 'i'), then we set insertModeLock to 'undefined', and only +# when the last editable element that came into focus -- which targetElement points to -- has been blurred. +# If insert mode is entered manually (via pressing 'i'), then we set targetElement to 'undefined', and only # leave insert mode when the user presses <ESC>. # Note. This returns the truthiness of target, which is required by isInsertMode. # -enterInsertModeWithoutShowingIndicator = (target) -> insertModeLock = target +enterInsertModeWithoutShowingIndicator = (target) -> + return # Disabled. exitInsertMode = (target) -> - if (target == undefined || insertModeLock == target) - insertModeLock = null - HUD.hide() + return # Disabled. isInsertMode = -> - return true if insertModeLock != null - # Some sites (e.g. inbox.google.com) change the contentEditable attribute on the fly (see #1245); and - # unfortunately, isEditable() is called *before* the change is made. Therefore, we need to re-check whether - # the active element is contentEditable. - document.activeElement and document.activeElement.isContentEditable and - enterInsertModeWithoutShowingIndicator document.activeElement + return false # Disabled. # should be called whenever rawQuery is modified. updateFindModeQuery = -> @@ -663,31 +751,53 @@ handleEnterForFindMode = -> document.body.classList.add("vimiumFindMode") settings.set("findModeRawQuery", findModeQuery.rawQuery) -performFindInPlace = -> - cachedScrollX = window.scrollX - cachedScrollY = window.scrollY +class FindMode extends Mode + constructor: -> + super + name: "find" + badge: "/" + exitOnEscape: true + exitOnClick: true + + keydown: (event) => + if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey + handleDeleteForFindMode() + @suppressEvent + else if event.keyCode == keyCodes.enter + handleEnterForFindMode() + @exit() + @suppressEvent + else + DomUtils.suppressPropagation(event) + handlerStack.stopBubblingAndFalse - query = if findModeQuery.isRegex then getNextQueryFromRegexMatches(0) else findModeQuery.parsedQuery + keypress: (event) -> + handlerStack.neverContinueBubbling -> + if event.keyCode > 31 + keyChar = String.fromCharCode event.charCode + handleKeyCharForFindMode keyChar if keyChar - # Search backwards first to "free up" the current word as eligible for the real forward search. This allows - # us to search in place without jumping around between matches as the query grows. - executeFind(query, { backwards: true, caseSensitive: !findModeQuery.ignoreCase }) + keyup: (event) => @suppressEvent - # We need to restore the scroll position because we might've lost the right position by searching - # backwards. - window.scrollTo(cachedScrollX, cachedScrollY) + exit: (event) -> + super() + handleEscapeForFindMode() if event?.type == "keydown" and KeyboardUtils.isEscape event + handleEscapeForFindMode() if event?.type == "click" + if findModeQueryHasResults and event?.type != "click" + new PostFindMode +performFindInPlace = -> + # Restore the selection. That way, we're always searching forward from the same place, so we find the right + # match as the user adds matching characters, or removes previously-matched characters. See #1434. + findModeRestoreSelection() + query = if findModeQuery.isRegex then getNextQueryFromRegexMatches(0) else findModeQuery.parsedQuery findModeQueryHasResults = executeFind(query, { caseSensitive: !findModeQuery.ignoreCase }) # :options is an optional dict. valid parameters are 'caseSensitive' and 'backwards'. executeFind = (query, options) -> + result = null options = options || {} - # rather hacky, but this is our way of signalling to the insertMode listener not to react to the focus - # changes that find() induces. - oldFindMode = findMode - findMode = true - document.body.classList.add("vimiumFindMode") # prevent find from matching its own search query in the HUD @@ -699,7 +809,13 @@ executeFind = (query, options) -> -> document.addEventListener("selectionchange", restoreDefaultSelectionHighlight, true) 0) - findMode = oldFindMode + # We are either in normal mode ("n"), or find mode ("/"). We are not in insert mode. Nevertheless, if a + # previous find landed in an editable element, then that element may still be activated. In this case, we + # don't want to leave it behind (see #1412). + if document.activeElement and DomUtils.isEditable document.activeElement + if not DomUtils.isSelected document.activeElement + document.activeElement.blur() + # we need to save the anchor node here because <esc> seems to nullify it, regardless of whether we do # preventDefault() findModeAnchorNode = document.getSelection().anchorNode @@ -712,13 +828,6 @@ focusFoundLink = -> link = getLinkFromSelection() link.focus() if link -isDOMDescendant = (parent, child) -> - node = child - while (node != null) - return true if (node == parent) - node = node.parentNode - false - selectFoundInputElement = -> # if the found text is in an input element, getSelection().anchorNode will be null, so we use activeElement # instead. however, since the last focused element might not be the one currently pointed to by find (e.g. @@ -726,7 +835,7 @@ selectFoundInputElement = -> # heuristic of checking that the last anchor node is an ancestor of our element. if (findModeQueryHasResults && document.activeElement && DomUtils.isSelectable(document.activeElement) && - isDOMDescendant(findModeAnchorNode, document.activeElement)) + DomUtils.isDOMDescendant(findModeAnchorNode, document.activeElement)) DomUtils.simulateSelect(document.activeElement) # the element has already received focus via find(), so invoke insert mode manually enterInsertModeWithoutShowingIndicator(document.activeElement) @@ -757,27 +866,11 @@ findAndFocus = (backwards) -> findModeQueryHasResults = executeFind(query, { backwards: backwards, caseSensitive: !findModeQuery.ignoreCase }) - if (!findModeQueryHasResults) + if findModeQueryHasResults + focusFoundLink() + new PostFindMode() if findModeQueryHasResults + else HUD.showForDuration("No matches for '" + findModeQuery.rawQuery + "'", 1000) - return - - # if we have found an input element via 'n', pressing <esc> immediately afterwards sends us into insert - # mode - elementCanTakeInput = document.activeElement && - DomUtils.isSelectable(document.activeElement) && - isDOMDescendant(findModeAnchorNode, document.activeElement) - if (elementCanTakeInput) - handlerStack.push({ - keydown: (event) -> - @remove() - if (KeyboardUtils.isEscape(event)) - DomUtils.simulateSelect(document.activeElement) - enterInsertModeWithoutShowingIndicator(document.activeElement) - return false # we have "consumed" this event, so do not propagate - return true - }) - - focusFoundLink() window.performFind = -> findAndFocus() @@ -890,13 +983,33 @@ showFindModeHUDForQuery = -> else HUD.show("/" + findModeQuery.rawQuery + " (No Matches)") +getCurrentRange = -> + selection = getSelection() + if selection.type == "None" + range = document.createRange() + range.setStart document.body, 0 + range.setEnd document.body, 0 + range + else + selection.collapseToStart() if selection.type == "Range" + selection.getRangeAt 0 + +findModeSaveSelection = -> + findModeInitialRange = getCurrentRange() + +findModeRestoreSelection = (range = findModeInitialRange) -> + selection = getSelection() + selection.removeAllRanges() + selection.addRange range + window.enterFindMode = -> + # Save the selection, so performFindInPlace can restore it. + findModeSaveSelection() findModeQuery = { rawQuery: "" } - findMode = true HUD.show("/") + new FindMode() exitFindMode = -> - findMode = false HUD.hide() window.showHelpDialog = (html, fid) -> @@ -986,10 +1099,10 @@ HUD = HUD.displayElement().style.display = "" showUpgradeNotification: (version) -> - HUD.upgradeNotificationElement().innerHTML = "Vimium has been updated to - <a class='vimiumReset' - href='https://chrome.google.com/extensions/detail/dbepggeogbaibhgnhhndojpepiihcmeb'> - #{version}</a>.<a class='vimiumReset close-button' href='#'>×</a>" + HUD.upgradeNotificationElement().innerHTML = "Vimium has been upgraded to #{version}. See + <a class='vimiumReset' target='_blank' + href='https://github.com/philc/vimium#release-notes'> + what's new</a>.<a class='vimiumReset close-button' href='#'>×</a>" links = HUD.upgradeNotificationElement().getElementsByTagName("a") links[0].addEventListener("click", HUD.onUpdateLinkClicked, false) links[1].addEventListener "click", (event) -> @@ -1067,10 +1180,44 @@ Tween = value = (elapsed / state.duration) * (state.to - state.from) + state.from state.onUpdate(value) +CursorHider = + # + # Hide the cursor when the browser scrolls, and prevent mouse from hovering while invisible. + # + cursorHideStyle: null + isScrolling: false + + onScroll: (event) -> + CursorHider.isScrolling = true + unless CursorHider.cursorHideStyle.parentElement + document.head.appendChild CursorHider.cursorHideStyle + + onMouseMove: (event) -> + if CursorHider.cursorHideStyle.parentElement and not CursorHider.isScrolling + CursorHider.cursorHideStyle.remove() + CursorHider.isScrolling = false + + init: -> + # Temporarily disabled pending consideration of #1359 (in particular, whether cursor hiding is too fragile + # as to provide a consistent UX). + return + + # Disable cursor hiding for Chrome versions less than 39.0.2171.71 due to a suspected browser error. + # See #1345 and #1348. + return unless Utils.haveChromeVersion "39.0.2171.71" + + @cursorHideStyle = document.createElement("style") + @cursorHideStyle.innerHTML = """ + body * {pointer-events: none !important; cursor: none !important;} + body, html {cursor: none !important;} + """ + window.addEventListener "mousemove", @onMouseMove + window.addEventListener "scroll", @onScroll + initializePreDomReady() -window.addEventListener("DOMContentLoaded", registerFrame) -window.addEventListener("unload", unregisterFrame) -window.addEventListener("DOMContentLoaded", initializeOnDomReady) +DomUtils.documentReady initializeOnDomReady +DomUtils.documentReady registerFrame +window.addEventListener "unload", unregisterFrame window.onbeforeunload = -> chrome.runtime.sendMessage( diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee index f1d2ccc5..6381fd7f 100644 --- a/content_scripts/vomnibar.coffee +++ b/content_scripts/vomnibar.coffee @@ -1,245 +1,51 @@ -Vomnibar = - vomnibarUI: null # the dialog instance for this window - completers: {} - - getCompleter: (name) -> - if (!(name of @completers)) - @completers[name] = new BackgroundCompleter(name) - @completers[name] - - # - # Activate the Vomnibox. - # - activateWithCompleter: (completerName, refreshInterval, initialQueryValue, selectFirstResult, forceNewTab) -> - completer = @getCompleter(completerName) - @vomnibarUI = new VomnibarUI() unless @vomnibarUI - completer.refresh() - @vomnibarUI.setInitialSelectionValue(if selectFirstResult then 0 else -1) - @vomnibarUI.setCompleter(completer) - @vomnibarUI.setRefreshInterval(refreshInterval) - @vomnibarUI.setForceNewTab(forceNewTab) - @vomnibarUI.show() - if (initialQueryValue) - @vomnibarUI.setQuery(initialQueryValue) - @vomnibarUI.update() - - activate: -> @activateWithCompleter("omni", 100) - activateInNewTab: -> @activateWithCompleter("omni", 100, null, false, true) - activateTabSelection: -> @activateWithCompleter("tabs", 0, null, true) - activateBookmarks: -> @activateWithCompleter("bookmarks", 0, null, true) - activateBookmarksInNewTab: -> @activateWithCompleter("bookmarks", 0, null, true, true) - activateEditUrl: -> @activateWithCompleter("omni", 100, window.location.href) - activateEditUrlInNewTab: -> @activateWithCompleter("omni", 100, window.location.href, false, true) - getUI: -> @vomnibarUI - - -class VomnibarUI - constructor: -> - @refreshInterval = 0 - @initDom() - - setQuery: (query) -> @input.value = query - - setInitialSelectionValue: (initialSelectionValue) -> - @initialSelectionValue = initialSelectionValue - - setCompleter: (completer) -> - @completer = completer - @reset() - - setRefreshInterval: (refreshInterval) -> @refreshInterval = refreshInterval - - setForceNewTab: (forceNewTab) -> @forceNewTab = forceNewTab - - show: -> - @box.style.display = "block" - @input.focus() - @handlerId = handlerStack.push keydown: @onKeydown.bind @ - - hide: -> - @box.style.display = "none" - @completionList.style.display = "none" - @input.blur() - handlerStack.remove @handlerId - - reset: -> - @input.value = "" - @updateTimer = null - @completions = [] - @selection = @initialSelectionValue - @update(true) - - updateSelection: -> - # We have taken the option to add some global state here (previousCompletionType) to tell if a search - # item has just appeared or disappeared, if that happens we either set the initialSelectionValue to 0 or 1 - # I feel that this approach is cleaner than bubbling the state up from the suggestion level - # so we just inspect it afterwards - if @completions[0] - if @previousCompletionType != "search" && @completions[0].type == "search" - @selection = 0 - else if @previousCompletionType == "search" && @completions[0].type != "search" - @selection = -1 - for i in [0...@completionList.children.length] - @completionList.children[i].className = (if i == @selection then "vomnibarSelected" else "") - @previousCompletionType = @completions[0].type if @completions[0] - - # - # Returns the user's action ("up", "down", "enter", "dismiss" or null) based on their keypress. - # We support the arrow keys and other shortcuts for moving, so this method hides that complexity. - # - actionFromKeyEvent: (event) -> - key = KeyboardUtils.getKeyChar(event) - if (KeyboardUtils.isEscape(event)) - return "dismiss" - else if (key == "up" || - (event.shiftKey && event.keyCode == keyCodes.tab) || - (event.ctrlKey && (key == "k" || key == "p"))) - return "up" - else if (key == "down" || - (event.keyCode == keyCodes.tab && !event.shiftKey) || - (event.ctrlKey && (key == "j" || key == "n"))) - return "down" - else if (event.keyCode == keyCodes.enter) - return "enter" - - onKeydown: (event) -> - action = @actionFromKeyEvent(event) - return true unless action # pass through - - openInNewTab = @forceNewTab || - (event.shiftKey || event.ctrlKey || KeyboardUtils.isPrimaryModifierKey(event)) - if (action == "dismiss") - @hide() - else if (action == "up") - @selection -= 1 - @selection = @completions.length - 1 if @selection < @initialSelectionValue - @input.value = @completions[@selection].url - @updateSelection() - else if (action == "down") - @selection += 1 - @selection = @initialSelectionValue if @selection == @completions.length - @input.value = @completions[@selection].url - @updateSelection() - else if (action == "enter") - # If they type something and hit enter without selecting a completion from our list of suggestions, - # try to open their query as a URL directly. If it doesn't look like a URL, we will search using - # google. - if (@selection == -1) - query = @input.value.trim() - # <Enter> on an empty vomnibar is a no-op. - return unless 0 < query.length - @hide() - chrome.runtime.sendMessage({ - handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" - url: query }) - else - @update true, => - # Shift+Enter will open the result in a new tab instead of the current tab. - @completions[@selection].performAction(openInNewTab) - @hide() - - # It seems like we have to manually suppress the event here and still return true. - DomUtils.suppressPropagation(event) - event.preventDefault() - true - - updateCompletions: (callback) -> - query = @input.value.trim() - - @completer.filter query, (completions) => - @completions = completions - @populateUiWithCompletions(completions) - callback() if callback - - populateUiWithCompletions: (completions) -> - # update completion list with the new data - @completionList.innerHTML = completions.map((completion) -> "<li>#{completion.html}</li>").join("") - @completionList.style.display = if completions.length > 0 then "block" else "none" - @selection = Math.min(Math.max(@initialSelectionValue, @selection), @completions.length - 1) - @updateSelection() - - update: (updateSynchronously, callback) -> - if (updateSynchronously) - # cancel scheduled update - if (@updateTimer != null) - window.clearTimeout(@updateTimer) - @updateCompletions(callback) - else if (@updateTimer != null) - # an update is already scheduled, don't do anything - return - else - # always update asynchronously for better user experience and to take some load off the CPU - # (not every keystroke will cause a dedicated update) - @updateTimer = setTimeout(=> - @updateCompletions(callback) - @updateTimer = null - @refreshInterval) - - initDom: -> - @box = Utils.createElementFromHtml( - """ - <div id="vomnibar" class="vimiumReset"> - <div class="vimiumReset vomnibarSearchArea"> - <input type="text" class="vimiumReset"> - </div> - <ul class="vimiumReset"></ul> - </div> - """) - @box.style.display = "none" - document.body.appendChild(@box) - - @input = document.querySelector("#vomnibar input") - @input.addEventListener "input", => @update() - @completionList = document.querySelector("#vomnibar ul") - @completionList.style.display = "none" - # -# Sends filter and refresh requests to a Vomnibox completer on the background page. +# This wraps the vomnibar iframe, which we inject into the page to provide the vomnibar. # -class BackgroundCompleter - # - name: The background page completer that you want to interface with. Either "omni", "tabs", or - # "bookmarks". */ - constructor: (@name) -> - @filterPort = chrome.runtime.connect({ name: "filterCompleter" }) - - refresh: -> chrome.runtime.sendMessage({ handler: "refreshCompleter", name: @name }) - - filter: (query, callback) -> - id = Utils.createUniqueId() - @filterPort.onMessage.addListener (msg) => - @filterPort.onMessage.removeListener(arguments.callee) - # The result objects coming from the background page will be of the form: - # { html: "", type: "", url: "" } - # type will be one of [tab, bookmark, history, domain]. - results = msg.results.map (result) -> - functionToCall = if (result.type == "tab") - BackgroundCompleter.completionActions.switchToTab.curry(result.tabId) - else - BackgroundCompleter.completionActions.navigateToUrl.curry(result.url) - result.performAction = functionToCall - result - callback(results) - - @filterPort.postMessage({ id: id, name: @name, query: query }) - -extend BackgroundCompleter, - # - # These are the actions we can perform when the user selects a result in the Vomnibox. - # - completionActions: - navigateToUrl: (url, openInNewTab) -> - # If the URL is a bookmarklet prefixed with javascript:, we shouldn't open that in a new tab. - if url.startsWith "javascript:" - script = document.createElement 'script' - script.textContent = decodeURIComponent(url["javascript:".length..]) - (document.head || document.documentElement).appendChild script - else - chrome.runtime.sendMessage( - handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" - url: url, - selected: openInNewTab) - - switchToTab: (tabId) -> chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: tabId }) +Vomnibar = + vomnibarUI: null + + activate: -> @open {completer:"omni"} + activateInNewTab: -> @open { + completer: "omni" + selectFirst: false + newTab: true + } + activateTabSelection: -> @open { + completer: "tabs" + selectFirst: true + } + activateBookmarks: -> @open { + completer: "bookmarks" + selectFirst: true + } + activateBookmarksInNewTab: -> @open { + completer: "bookmarks" + selectFirst: true + newTab: true + } + activateEditUrl: -> @open { + completer: "omni" + selectFirst: false + query: window.location.href + } + activateEditUrlInNewTab: -> @open { + completer: "omni" + selectFirst: false + query: window.location.href + newTab: true + } + + init: -> + unless @vomnibarUI? + @vomnibarUI = new UIComponent "pages/vomnibar.html", "vomnibarFrame", (event) => + @vomnibarUI.hide() if event.data == "hide" + + # This function opens the vomnibar. It accepts options, a map with the values: + # completer - The completer to fetch results from. + # query - Optional. Text to prefill the Vomnibar with. + # selectFirst - Optional, boolean. Whether to select the first entry. + # newTab - Optional, boolean. Whether to open the result in a new tab. + open: (options) -> @vomnibarUI.activate options root = exports ? window root.Vomnibar = Vomnibar diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index 21018049..4f36e395 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -2,10 +2,11 @@ DomUtils = # # Runs :callback if the DOM has loaded, otherwise runs it on load # - documentReady: do -> - loaded = false - window.addEventListener("DOMContentLoaded", -> loaded = true) - (callback) -> if loaded then callback() else window.addEventListener("DOMContentLoaded", callback) + documentReady: (func) -> + if document.readyState == "loading" + window.addEventListener "DOMContentLoaded", func + else + func() # # Adds a list of elements to a page. @@ -33,47 +34,24 @@ DomUtils = makeXPath: (elementArray) -> xpath = [] for element in elementArray - xpath.push("//" + element, "//xhtml:" + element) + xpath.push(".//" + element, ".//xhtml:" + element) xpath.join(" | ") + # Evaluates an XPath on the whole document, or on the contents of the fullscreen element if an element is + # fullscreen. evaluateXPath: (xpath, resultType) -> + contextNode = + if document.webkitIsFullScreen then document.webkitFullscreenElement else document.documentElement namespaceResolver = (namespace) -> if (namespace == "xhtml") then "http://www.w3.org/1999/xhtml" else null - document.evaluate(xpath, document.documentElement, namespaceResolver, resultType, null) + document.evaluate(xpath, contextNode, namespaceResolver, resultType, null) # # Returns the first visible clientRect of an element if it exists. Otherwise it returns null. # getVisibleClientRect: (element) -> # Note: this call will be expensive if we modify the DOM in between calls. - clientRects = ({ - top: clientRect.top, right: clientRect.right, bottom: clientRect.bottom, left: clientRect.left, - width: clientRect.width, height: clientRect.height - } for clientRect in element.getClientRects()) - - for clientRect in clientRects - if (clientRect.top < 0) - clientRect.height += clientRect.top - clientRect.top = 0 - - if (clientRect.left < 0) - clientRect.width += clientRect.left - clientRect.left = 0 - - if (clientRect.top >= window.innerHeight - 4 || clientRect.left >= window.innerWidth - 4) - continue - - if (clientRect.width < 3 || clientRect.height < 3) - continue - - # eliminate invisible elements (see test_harnesses/visibility_test.html) - computedStyle = window.getComputedStyle(element, null) - if (computedStyle.getPropertyValue('visibility') != 'visible' || - computedStyle.getPropertyValue('display') == 'none' || - computedStyle.getPropertyValue('opacity') == '0') - continue - - return clientRect + clientRects = (Rect.copy clientRect for clientRect in element.getClientRects()) for clientRect in clientRects # If the link has zero dimensions, it may be wrapping visible @@ -86,22 +64,132 @@ DomUtils = continue if (computedStyle.getPropertyValue('float') == 'none' && computedStyle.getPropertyValue('position') != 'absolute') childClientRect = @getVisibleClientRect(child) - continue if (childClientRect == null) + continue if childClientRect == null or childClientRect.width < 3 or childClientRect.height < 3 return childClientRect + + else + clientRect = @cropRectToVisible clientRect + + continue if clientRect == null or clientRect.width < 3 or clientRect.height < 3 + + # eliminate invisible elements (see test_harnesses/visibility_test.html) + computedStyle = window.getComputedStyle(element, null) + if (computedStyle.getPropertyValue('visibility') != 'visible' || + computedStyle.getPropertyValue('display') == 'none') + continue + + return clientRect + null # - # Selectable means the element has a text caret; this is not the same as "focusable". + # Bounds the rect by the current viewport dimensions. If the rect is offscreen or has a height or width < 3 + # then null is returned instead of a rect. + # + cropRectToVisible: (rect) -> + boundedRect = Rect.create( + Math.max(rect.left, 0) + Math.max(rect.top, 0) + rect.right + rect.bottom + ) + if boundedRect.top >= window.innerHeight - 4 or boundedRect.left >= window.innerWidth - 4 + null + else + boundedRect + + # + # Get the client rects for the <area> elements in a <map> based on the position of the <img> element using + # the map. Returns an array of rects. + # + getClientRectsForAreas: (imgClientRect, areas) -> + rects = [] + for area in areas + coords = area.coords.split(",").map((coord) -> parseInt(coord, 10)) + shape = area.shape.toLowerCase() + if shape in ["rect", "rectangle"] # "rectangle" is an IE non-standard. + [x1, y1, x2, y2] = coords + else if shape in ["circle", "circ"] # "circ" is an IE non-standard. + [x, y, r] = coords + diff = r / Math.sqrt 2 # Gives us an inner square + x1 = x - diff + x2 = x + diff + y1 = y - diff + y2 = y + diff + else if shape == "default" + [x1, y1, x2, y2] = [0, 0, imgClientRect.width, imgClientRect.height] + else + # Just consider the rectangle surrounding the first two points in a polygon. It's possible to do + # something more sophisticated, but likely not worth the effort. + [x1, y1, x2, y2] = coords + + rect = Rect.translate (Rect.create x1, y1, x2, y2), imgClientRect.left, imgClientRect.top + rect = @cropRectToVisible rect + + rects.push {element: area, rect: rect} if rect and not isNaN rect.top + rects + + # + # Selectable means that we should use the simulateSelect method to activate the element instead of a click. + # + # The html5 input types that should use simulateSelect are: + # ["date", "datetime", "datetime-local", "email", "month", "number", "password", "range", "search", + # "tel", "text", "time", "url", "week"] + # An unknown type will be treated the same as "text", in the same way that the browser does. # isSelectable: (element) -> - selectableTypes = ["search", "text", "password"] - (element.nodeName.toLowerCase() == "input" && selectableTypes.indexOf(element.type) >= 0) || - element.nodeName.toLowerCase() == "textarea" + unselectableTypes = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "reset", "submit"] + (element.nodeName.toLowerCase() == "input" && unselectableTypes.indexOf(element.type) == -1) || + element.nodeName.toLowerCase() == "textarea" || element.isContentEditable + + # Input or text elements are considered focusable and able to receieve their own keyboard events, and will + # enter insert mode if focused. Also note that the "contentEditable" attribute can be set on any element + # which makes it a rich text editor, like the notes on jjot.com. + isEditable: (element) -> + return true if element.isContentEditable + nodeName = element.nodeName?.toLowerCase() + # Use a blacklist instead of a whitelist because new form controls are still being implemented for html5. + if nodeName == "input" and element.type not in ["radio", "checkbox"] + return true + nodeName in ["textarea", "select"] + + # Embedded elements like Flash and quicktime players can obtain focus. + isEmbed: (element) -> + element.nodeName?.toLowerCase() in ["embed", "object"] + + isFocusable: (element) -> + @isEditable(element) or @isEmbed element + + isDOMDescendant: (parent, child) -> + node = child + while (node != null) + return true if (node == parent) + node = node.parentNode + false + + # True if element contains the active selection range. + isSelected: (element) -> + if element.isContentEditable + node = document.getSelection()?.anchorNode + node and @isDOMDescendant element, node + else + # Note. This makes the wrong decision if the user has placed the caret at the start of element. We + # cannot distinguish that case from the user having made no selection. + element.selectionStart? and element.selectionEnd? and element.selectionEnd != 0 simulateSelect: (element) -> - element.focus() - # When focusing a textbox, put the selection caret at the end of the textbox's contents. - element.setSelectionRange(element.value.length, element.value.length) + # If element is already active, then we don't move the selection. However, we also won't get a new focus + # event. So, instead we pretend (to any active modes which care, e.g. PostFindMode) that element has been + # clicked. + if element == document.activeElement and DomUtils.isEditable document.activeElement + handlerStack.bubbleEvent "click", target: element + else + element.focus() + unless @isSelected element + # When focusing a textbox (without an existing selection), put the selection caret at the end of the + # textbox's contents. For some HTML5 input types (eg. date) we can't position the caret, so we wrap + # this with a try. + try element.setSelectionRange(element.value.length, element.value.length) simulateClick: (element, modifiers) -> modifiers ||= {} @@ -134,5 +222,14 @@ 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 + root = exports ? window root.DomUtils = DomUtils diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee index 858f2ec9..76d835b7 100644 --- a/lib/handler_stack.coffee +++ b/lib/handler_stack.coffee @@ -1,37 +1,99 @@ root = exports ? window -class root.HandlerStack +class HandlerStack constructor: -> + @debug = false + @eventNumber = 0 @stack = [] @counter = 0 - genId: -> @counter = ++@counter & 0xffff + # A handler should return this value to immediately discontinue bubbling and pass the event on to the + # underlying page. + @stopBubblingAndTrue = new Object() - # Adds a handler to the stack. Returns a unique ID for that handler that can be used to remove it later. + # A handler should return this value to indicate that the event has been consumed, and no further + # processing should take place. The event does not propagate to the underlying page. + @stopBubblingAndFalse = new Object() + + # A handler should return this value to indicate that bubbling should be restarted. Typically, this is + # used when, while bubbling an event, a new mode is pushed onto the stack. + @restartBubbling = new Object() + + # Adds a handler to the top of the stack. Returns a unique ID for that handler that can be used to remove it + # later. push: (handler) -> - handler.id = @genId() + handler._name ||= "anon-#{@counter}" @stack.push handler - handler.id + handler.id = ++@counter + + # As above, except the new handler is added to the bottom of the stack. + unshift: (handler) -> + handler._name ||= "anon-#{@counter}" + handler._name += "/unshift" + @stack.unshift handler + handler.id = ++@counter - # Called whenever we receive a key event. Each individual handler has the option to stop the event's - # propagation by returning a falsy value. + # Called whenever we receive a key or other event. Each individual handler has the option to stop the + # event's propagation by returning a falsy value, or stop bubbling by returning @stopBubblingAndFalse or + # @stopBubblingAndTrue. bubbleEvent: (type, event) -> - for i in [(@stack.length - 1)..0] by -1 - handler = @stack[i] - # We need to check for existence of handler because the last function call may have caused the release - # of more than one handler. - if handler && handler[type] + @eventNumber += 1 + # We take a copy of the array in order to avoid interference from concurrent removes (for example, to + # avoid calling the same handler twice, because elements have been spliced out of the array by remove). + for handler in @stack[..].reverse() + # A handler may have been removed (handler.id == null), so check. + if handler?.id and handler[type] @currentId = handler.id - passThrough = handler[type].call(@, event) - if not passThrough - DomUtils.suppressEvent(event) + result = handler[type].call @, event + @logResult type, event, handler, result if @debug + if not result + DomUtils.suppressEvent event if @isChromeEvent event return false + return true if result == @stopBubblingAndTrue + return false if result == @stopBubblingAndFalse + return @bubbleEvent type, event if result == @restartBubbling true remove: (id = @currentId) -> for i in [(@stack.length - 1)..0] by -1 handler = @stack[i] if handler.id == id + # Mark the handler as removed. + handler.id = null @stack.splice(i, 1) break + + # The handler stack handles chrome events (which may need to be suppressed) and internal (pseudo) events. + # This checks whether the event at hand is a chrome event. + isChromeEvent: (event) -> + event?.preventDefault? or event?.stopImmediatePropagation? + + # Convenience wrappers. Handlers must return an approriate value. These are wrappers which handlers can + # use to always return the same value. This then means that the handler itself can be implemented without + # regard to its return value. + alwaysContinueBubbling: (handler) -> + handler() + true + + neverContinueBubbling: (handler) -> + handler() + false + + # Debugging. + logResult: (type, event, handler, result) -> + # FIXME(smblott). Badge updating is too noisy, so we filter it out. However, we do need to look at how + # many badge update events are happening. It seems to be more than necessary. We also filter out + # registerKeyQueue as unnecessarily noisy and not particularly helpful. + return if type in [ "updateBadge", "registerKeyQueue" ] + label = + switch result + when @stopBubblingAndTrue then "stop/true" + when @stopBubblingAndFalse then "stop/false" + when @restartBubbling then "rebubble" + when true then "continue" + label ||= if result then "continue/truthy" else "suppress" + console.log "#{@eventNumber}", type, handler._name, label + +root.HandlerStack = HandlerStack +root.handlerStack = new HandlerStack() diff --git a/lib/keyboard_utils.coffee b/lib/keyboard_utils.coffee index d2a843f9..30d99656 100644 --- a/lib/keyboard_utils.coffee +++ b/lib/keyboard_utils.coffee @@ -55,6 +55,12 @@ KeyboardUtils = # c-[ is mapped to ESC in Vim by default. (event.keyCode == @keyCodes.ESC) || (event.ctrlKey && @getKeyChar(event) == '[') + # 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 + @getKeyChar(event)?.length == 1 + KeyboardUtils.init() root = exports ? window diff --git a/lib/rect.coffee b/lib/rect.coffee new file mode 100644 index 00000000..adc1fc36 --- /dev/null +++ b/lib/rect.coffee @@ -0,0 +1,82 @@ +# Commands for manipulating rects. +Rect = + # Create a rect given the top left and bottom right corners. + create: (x1, y1, x2, y2) -> + bottom: y2 + top: y1 + left: x1 + right: x2 + width: x2 - x1 + height: y2 - y1 + + copy: (rect) -> + bottom: rect.bottom + top: rect.top + left: rect.left + right: rect.right + width: rect.width + height: rect.height + + # Translate a rect by x horizontally and y vertically. + translate: (rect, x = 0, y = 0) -> + bottom: rect.bottom + y + top: rect.top + y + left: rect.left + x + right: rect.right + x + width: rect.width + height: rect.height + + # Subtract rect2 from rect1, returning an array of rects which are in rect1 but not rect2. + subtract: (rect1, rect2) -> + # Bound rect2 by rect1 + rect2 = @create( + Math.max(rect1.left, rect2.left), + Math.max(rect1.top, rect2.top), + Math.min(rect1.right, rect2.right), + Math.min(rect1.bottom, rect2.bottom) + ) + + # If bounding rect2 has made the width or height negative, rect1 does not contain rect2. + return [Rect.copy rect1] if rect2.width < 0 or rect2.height < 0 + + # + # All the possible rects, in the order + # +-+-+-+ + # |1|2|3| + # +-+-+-+ + # |4| |5| + # +-+-+-+ + # |6|7|8| + # +-+-+-+ + # where the outer rectangle is rect1 and the inner rectangle is rect 2. Note that the rects may be of + # width or height 0. + # + rects = [ + # Top row. + @create rect1.left, rect1.top, rect2.left, rect2.top + @create rect2.left, rect1.top, rect2.right, rect2.top + @create rect2.right, rect1.top, rect1.right, rect2.top + # Middle row. + @create rect1.left, rect2.top, rect2.left, rect2.bottom + @create rect2.right, rect2.top, rect1.right, rect2.bottom + # Bottom row. + @create rect1.left, rect2.bottom, rect2.left, rect1.bottom + @create rect2.left, rect2.bottom, rect2.right, rect1.bottom + @create rect2.right, rect2.bottom, rect1.right, rect1.bottom + ] + + rects.filter (rect) -> rect.height > 0 and rect.width > 0 + + contains: (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] + true + +root = exports ? window +root.Rect = Rect diff --git a/lib/utils.coffee b/lib/utils.coffee index b7f8731a..661f7e84 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -26,11 +26,10 @@ Utils = -> id += 1 hasChromePrefix: do -> - chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:" ] + chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:", "javascript:" ] (url) -> - if 0 < url.indexOf ":" - for prefix in chromePrefixes - return true if url.startsWith prefix + for prefix in chromePrefixes + return true if url.startsWith prefix false hasFullUrlPrefix: do -> @@ -88,11 +87,17 @@ Utils = # Fallback: no URL return false + # Map a search query to its URL encoded form. The query may be either a string or an array of strings. + # E.g. "BBC Sport" -> "BBC+Sport". + createSearchQuery: (query) -> + query = query.split(/\s+/) if typeof(query) == "string" + query.map(encodeURIComponent).join "+" + # Creates a search URL from the given :query. createSearchUrl: (query) -> - # it would be better to pull the default search engine from chrome itself, - # but it is not clear if/how that is possible - Settings.get("searchUrl") + encodeURIComponent(query) + # It would be better to pull the default search engine from chrome itself. However, unfortunately chrome + # does not provide an API for doing so. + Settings.get("searchUrl") + @createSearchQuery query # Converts :string into a Google search if it's not already a URL. We don't bother with escaping characters # as Chrome will do that for us. @@ -110,6 +115,12 @@ Utils = # detects both literals and dynamically created strings isString: (obj) -> typeof obj == 'string' or obj instanceof String + # Transform "zjkjkabz" into "abjkz". + distinctCharacters: (str) -> + unique = "" + for char in str.split("").sort() + unique += char unless 0 <= unique.indexOf char + unique # Compares two version strings (e.g. "1.1" and "1.5") and returns # -1 if versionA is < versionB, 0 if they're equal, and 1 if versionA is > versionB. @@ -125,6 +136,11 @@ Utils = return 1 0 + # True if the current Chrome version is at least the required version. + haveChromeVersion: (required) -> + chromeVersion = navigator.appVersion.match(/Chrome\/(.*?) /)?[1] + chromeVersion and 0 <= Utils.compareVersions chromeVersion, required + # Zip two (or more) arrays: # - Utils.zip([ [a,b], [1,2] ]) returns [ [a,1], [b,2] ] # - Length of result is `arrays[0].length`. diff --git a/manifest.json b/manifest.json index 3792458b..a04d8c0e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Vimium", - "version": "1.45", + "version": "1.49", "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", @@ -35,12 +35,19 @@ "js": ["lib/utils.js", "lib/keyboard_utils.js", "lib/dom_utils.js", + "lib/rect.js", "lib/handler_stack.js", "lib/clipboard.js", + "content_scripts/ui_component.js", "content_scripts/link_hints.js", "content_scripts/vomnibar.js", "content_scripts/scroller.js", "content_scripts/marks.js", + "content_scripts/mode.js", + "content_scripts/mode_insert.js", + "content_scripts/mode_passkeys.js", + "content_scripts/mode_find.js", + "content_scripts/mode_visual.js", "content_scripts/vimium_frontend.js" ], "css": ["content_scripts/vimium.css"], @@ -57,5 +64,8 @@ "browser_action": { "default_icon": "icons/browser_action_disabled.png", "default_popup": "pages/popup.html" - } + }, + "web_accessible_resources": [ + "pages/vomnibar.html" + ] } diff --git a/pages/exclusions.html b/pages/exclusions.html new file mode 100644 index 00000000..b09f2895 --- /dev/null +++ b/pages/exclusions.html @@ -0,0 +1,13 @@ +<table id="exclusionRules"> + <tr> + <td><span class="exclusionHeaderText">Patterns</span></td> + <td><span class="exclusionHeaderText">Keys</span></td> + </tr> +</table> +<template id="exclusionRuleTemplate"> + <tr class="exclusionRuleTemplateInstance"> + <td><input/ type="text" class="pattern" spellcheck="false" placeholder="URL pattern"></td> + <td class="exclusionRulePassKeys"><input/ type="text" class="passKeys" spellcheck="false" placeholder="Exclude keys"></td> + <td class="exclusionRemoveButton"><input/ type="button" class="exclusionRemoveButtonButton" value="✖"></td> + </tr> +</template> diff --git a/pages/options.coffee b/pages/options.coffee index f5968eb9..93c9b503 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -1,6 +1,8 @@ $ = (id) -> document.getElementById id +bgUtils = chrome.extension.getBackgroundPage().Utils bgSettings = chrome.extension.getBackgroundPage().Settings +bgExclusions = chrome.extension.getBackgroundPage().Exclusions # # Class hierarchy for various types of option. @@ -11,10 +13,9 @@ class Option # Static. Array of all options. @all = [] - constructor: (field,enableSaveButton) -> - @field = field + constructor: (@field,@onUpdated) -> @element = $(@field) - @element.addEventListener "change", enableSaveButton + @element.addEventListener "change", @onUpdated @fetch() Option.all.push @ @@ -41,14 +42,6 @@ class Option # Static method. @saveOptions: -> Option.all.map (option) -> option.save() - $("saveOptions").disabled = true - - # Used by text options. <ctrl-Enter> saves all options. - activateCtrlEnterListener: (element) -> - element.addEventListener "keyup", (event) -> - if event.ctrlKey and event.keyCode == 13 - element.blur() - Option.saveOptions() # Abstract method; only implemented in sub-classes. # Populate the option's DOM element (@element) with the setting's current value. @@ -63,18 +56,16 @@ class NumberOption extends Option readValueFromElement: -> parseFloat @element.value class TextOption extends Option - constructor: (field,enableSaveButton) -> - super(field,enableSaveButton) - @element.addEventListener "input", enableSaveButton - @activateCtrlEnterListener @element + constructor: (args...) -> + super(args...) + @element.addEventListener "input", @onUpdated populateElement: (value) -> @element.value = value readValueFromElement: -> @element.value.trim() class NonEmptyTextOption extends Option - constructor: (field,enableSaveButton) -> - super(field,enableSaveButton) - @element.addEventListener "input", enableSaveButton - @activateCtrlEnterListener @element + constructor: (args...) -> + super(args...) + @element.addEventListener "input", @onUpdated populateElement: (value) -> @element.value = value # If the new value is not empty, then return it. Otherwise, restore the default value. @@ -88,22 +79,24 @@ class ExclusionRulesOption extends Option constructor: (args...) -> super(args...) $("exclusionAddButton").addEventListener "click", (event) => - @appendRule { pattern: "", passKeys: "" } - @maintainExclusionMargin() - # Focus the pattern element in the new rule. - @element.children[@element.children.length-1].children[0].children[0].focus() - # Scroll the new rule into view. + @addRule() + + # Add a new rule, focus its pattern, scroll it into view, and return the newly-added element. On the + # options page, there is no current URL, so there is no initial pattern. This is the default. On the popup + # page (see ExclusionRulesOnPopupOption), the pattern is pre-populated based on the current tab's URL. + addRule: (pattern="") -> + element = @appendRule { pattern: pattern, passKeys: "" } + @getPattern(element).focus() exclusionScrollBox = $("exclusionScrollBox") exclusionScrollBox.scrollTop = exclusionScrollBox.scrollHeight + @onUpdated() + element populateElement: (rules) -> - while @element.firstChild - @element.removeChild @element.firstChild for rule in rules @appendRule rule - @maintainExclusionMargin() - # Append a row for a new rule. + # Append a row for a new rule. Return the newly-added element. appendRule: (rule) -> content = document.querySelector('#exclusionRuleTemplate').content row = document.importNode content, true @@ -111,25 +104,22 @@ class ExclusionRulesOption extends Option for field in ["pattern", "passKeys"] element = row.querySelector ".#{field}" element.value = rule[field] - @activateCtrlEnterListener element for event in [ "input", "change" ] - element.addEventListener event, enableSaveButton + element.addEventListener event, @onUpdated - remove = row.querySelector ".exclusionRemoveButton" - remove.addEventListener "click", (event) => - row = event.target.parentNode.parentNode - row.parentNode.removeChild row - enableSaveButton() - @maintainExclusionMargin() + @getRemoveButton(row).addEventListener "click", (event) => + rule = event.target.parentNode.parentNode + rule.parentNode.removeChild rule + @onUpdated() @element.appendChild row + @element.children[@element.children.length-1] readValueFromElement: -> rules = - for element in @element.children - pattern = element.children[0].firstChild.value.trim() - passKeys = element.children[1].firstChild.value.trim() - { pattern: pattern, passKeys: passKeys } + for element in @element.getElementsByClassName "exclusionRuleTemplateInstance" + pattern: @getPattern(element).value.split(/\s+/).join "" + passKeys: @getPassKeys(element).value.split(/\s+/).join "" rules.filter (rule) -> rule.pattern areEqual: (a,b) -> @@ -138,53 +128,123 @@ class ExclusionRulesOption extends Option flatten = (rule) -> if rule and rule.pattern then rule.pattern + "\n" + rule.passKeys else "" a.map(flatten).join("\n") == b.map(flatten).join("\n") - # Hack. There has to be a better way than... - # The y-axis scrollbar for "exclusionRules" is only displayed if it is needed. When visible, it appears on - # top of the enclosed content (partially obscuring it). Here, we adjust the margin of the "Remove" button to - # compensate. - maintainExclusionMargin: -> - scrollBox = $("exclusionScrollBox") - margin = if scrollBox.clientHeight < scrollBox.scrollHeight then "16px" else "0px" - for element in scrollBox.getElementsByClassName "exclusionRemoveButton" - element.style["margin-right"] = margin + # Accessors for the three main sub-elements of an "exclusionRuleTemplateInstance". + getPattern: (element) -> element.querySelector(".pattern") + getPassKeys: (element) -> element.querySelector(".passKeys") + getRemoveButton: (element) -> element.querySelector(".exclusionRemoveButtonButton") -# -# Operations for page elements. -enableSaveButton = -> - $("saveOptions").removeAttribute "disabled" - -# Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints". -maintainLinkHintsView = -> - hide = (el) -> el.parentNode.parentNode.style.display = "none" - show = (el) -> el.parentNode.parentNode.style.display = "table-row" - if $("filterLinkHints").checked - hide $("linkHintCharacters") - show $("linkHintNumbers") - else - show $("linkHintCharacters") - hide $("linkHintNumbers") - -toggleAdvancedOptions = - do (advancedMode=false) -> - (event) -> - if advancedMode - $("advancedOptions").style.display = "none" - $("advancedOptionsLink").innerHTML = "Show advanced options…" +# ExclusionRulesOnPopupOption is ExclusionRulesOption, extended with some UI tweeks suitable for use in the +# page popup. This also differs from ExclusionRulesOption in that, on the page popup, there is always a URL +# (@url) associated with the current tab. +class ExclusionRulesOnPopupOption extends ExclusionRulesOption + constructor: (@url, args...) -> + super(args...) + + addRule: -> + element = super @generateDefaultPattern() + @activatePatternWatcher element + # ExclusionRulesOption.addRule()/super() has focused the pattern. Here, focus the passKeys instead; + # because, in the popup, we already have a pattern, so the user is more likely to edit the passKeys. + @getPassKeys(element).focus() + # Return element (for consistency with ExclusionRulesOption.addRule()). + element + + populateElement: (rules) -> + super(rules) + elements = @element.getElementsByClassName "exclusionRuleTemplateInstance" + @activatePatternWatcher element for element in elements + + haveMatch = false + for element in elements + pattern = @getPattern(element).value.trim() + if 0 <= @url.search bgExclusions.RegexpCache.get pattern + haveMatch = true + @getPassKeys(element).focus() + else + element.style.display = 'none' + @addRule() unless haveMatch + + # Provide visual feedback (make it red) when a pattern does not match the current tab's URL. + activatePatternWatcher: (element) -> + patternElement = element.children[0].firstChild + patternElement.addEventListener "keyup", => + if @url.match bgExclusions.RegexpCache.get patternElement.value + patternElement.title = patternElement.style.color = "" else - $("advancedOptions").style.display = "table-row-group" - $("advancedOptionsLink").innerHTML = "Hide advanced options" - advancedMode = !advancedMode - event.preventDefault() + patternElement.style.color = "red" + patternElement.title = "Red text means that the pattern does not\nmatch the current URL." + + # Generate a default exclusion-rule pattern from a URL. This is then used to pre-populate the pattern on + # the page popup. + generateDefaultPattern: -> + if /^https?:\/\/./.test @url + # The common use case is to disable Vimium at the domain level. + # Generate "https?://www.example.com/*" from "http://www.example.com/path/to/page.html". + "https?:/" + @url.split("/",3)[1..].join("/") + "/*" + else if /^[a-z]{3,}:\/\/./.test @url + # Anything else which seems to be a URL. + @url.split("/",3).join("/") + "/*" + else + @url + "*" + +initOptionsPage = -> + onUpdated = -> + $("saveOptions").removeAttribute "disabled" + $("saveOptions").innerHTML = "Save Changes" + + # Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints". + maintainLinkHintsView = -> + hide = (el) -> el.parentNode.parentNode.style.display = "none" + show = (el) -> el.parentNode.parentNode.style.display = "table-row" + if $("filterLinkHints").checked + hide $("linkHintCharacters") + show $("linkHintNumbers") + else + show $("linkHintCharacters") + hide $("linkHintNumbers") + + toggleAdvancedOptions = + do (advancedMode=false) -> + (event) -> + if advancedMode + $("advancedOptions").style.display = "none" + $("advancedOptionsLink").innerHTML = "Show advanced options…" + else + $("advancedOptions").style.display = "table-row-group" + $("advancedOptionsLink").innerHTML = "Hide advanced options" + advancedMode = !advancedMode + event.preventDefault() + # Prevent the "advanced options" link from retaining the focus. + document.activeElement.blur() + + activateHelpDialog = -> + showHelpDialog chrome.extension.getBackgroundPage().helpDialogHtml(true, true, "Command Listing"), frameId + # Prevent the "show help" link from retaining the focus when clicked. + document.activeElement.blur() + + saveOptions = -> + Option.saveOptions() + $("saveOptions").disabled = true + $("saveOptions").innerHTML = "No Changes" -activateHelpDialog = -> - showHelpDialog chrome.extension.getBackgroundPage().helpDialogHtml(true, true, "Command Listing"), frameId + $("saveOptions").addEventListener "click", saveOptions + $("advancedOptionsLink").addEventListener "click", toggleAdvancedOptions + $("showCommands").addEventListener "click", activateHelpDialog + $("filterLinkHints").addEventListener "click", maintainLinkHintsView -# -# Initialization. -document.addEventListener "DOMContentLoaded", -> + for element in document.getElementsByClassName "nonEmptyTextOption" + element.className = element.className + " example info" + element.innerHTML = "Leave empty to reset this option." + + maintainLinkHintsView() + window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled + + document.addEventListener "keyup", (event) -> + if event.ctrlKey and event.keyCode == 13 + document.activeElement.blur() if document?.activeElement?.blur + saveOptions() - # Populate options. The constructor adds each new object to "Option.all". - new type(name,enableSaveButton) for name, type of { + options = exclusionRules: ExclusionRulesOption filterLinkHints: CheckBoxOption hideHud: CheckBoxOption @@ -196,20 +256,67 @@ document.addEventListener "DOMContentLoaded", -> previousPatterns: NonEmptyTextOption regexFindMode: CheckBoxOption scrollStepSize: NumberOption + smoothScroll: CheckBoxOption searchEngines: TextOption searchUrl: NonEmptyTextOption userDefinedLinkHintCss: TextOption - } - $("saveOptions").addEventListener "click", Option.saveOptions - $("advancedOptionsLink").addEventListener "click", toggleAdvancedOptions - $("showCommands").addEventListener "click", activateHelpDialog - $("filterLinkHints").addEventListener "click", maintainLinkHintsView + # Populate options. The constructor adds each new object to "Option.all". + for name, type of options + new type(name,onUpdated) + +initPopupPage = -> + chrome.tabs.getSelected null, (tab) -> + exclusions = null + document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html") + + updateState = -> + rule = bgExclusions.getRule tab.url, exclusions.readValueFromElement() + $("state").innerHTML = "Vimium will " + + if rule and rule.passKeys + "exclude <span class='code'>#{rule.passKeys}</span>" + else if rule + "be disabled" + else + "be enabled" + + onUpdated = -> + $("helpText").innerHTML = "Type <strong>Ctrl-Enter</strong> to save and close." + $("saveOptions").removeAttribute "disabled" + $("saveOptions").innerHTML = "Save Changes" + updateState() if exclusions + + saveOptions = -> + Option.saveOptions() + $("saveOptions").innerHTML = "Saved" + $("saveOptions").disabled = true + chrome.tabs.query { windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, (tabs) -> + chrome.extension.getBackgroundPage().updateActiveState(tabs[0].id) + + $("saveOptions").addEventListener "click", saveOptions + + document.addEventListener "keyup", (event) -> + if event.ctrlKey and event.keyCode == 13 + saveOptions() + window.close() - for element in document.getElementsByClassName "nonEmptyTextOption" - element.className = element.className + " example info" - element.innerHTML = "Leave empty to reset this option." + # Populate options. Just one, here. + exclusions = new ExclusionRulesOnPopupOption(tab.url, "exclusionRules", onUpdated) - maintainLinkHintsView() - window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled + updateState() + document.addEventListener "keyup", updateState + +# +# Initialization. +document.addEventListener "DOMContentLoaded", -> + xhr = new XMLHttpRequest() + xhr.open 'GET', chrome.extension.getURL('pages/exclusions.html'), true + xhr.onreadystatechange = -> + if xhr.readyState == 4 + $("exclusionScrollBox").innerHTML = xhr.responseText + switch location.pathname + when "/pages/options.html" then initOptionsPage() + when "/pages/popup.html" then initPopupPage() + + xhr.send() diff --git a/pages/options.css b/pages/options.css new file mode 100644 index 00000000..5b098c8f --- /dev/null +++ b/pages/options.css @@ -0,0 +1,232 @@ +/* NOTE: This stylesheet is included in both options.html and popup.html. So changes here affect + both of these. */ +body { + font: 14px "DejaVu Sans", "Arial", sans-serif; + color: #303942; + margin: 0 auto; +} +a, a:visited { color: #15c; } +a:active { color: #052577; } +div#wrapper, #footerWrapper { + width: 540px; + margin-left: 35px; +} +header { + font-size: 18px; + font-weight: normal; + border-bottom: 1px solid #eee; + padding: 20px 0 15px 0; + width: 100%; +} +button { + -webkit-user-select: none; + -webkit-appearance: none; + background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede); + border: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 2px; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75); + color: #444; + font: inherit; + text-shadow: 0 1px 0 #f0f0f0; + height: 24px; + font-size: 12px; + padding: 0 10px; +} +button:hover { + background-image: -webkit-linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0); + border-color: rgba(0, 0, 0, 0.3); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12), inset 0 1px 2px rgba(255, 255, 255, 0.95); + color: black; +} +button:active { + background-image: -webkit-linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7); + box-shadow: none; + text-shadow: none; +} +button[disabled], button[disabled]:hover, button[disabled]:active { + background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede); + border: 1px solid rgba(0, 0, 0, 0.25); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75); + text-shadow: 0 1px 0 #f0f0f0; + color: #888; +} +input[type="checkbox"] { + -webkit-user-select: none; +} +label:hover { + color: black; +} +pre, code, .code { + font-family: Consolas, "Liberation Mono", Courier, monospace; +} +pre { + margin: 5px; + border-left: 1px solid #eee; + padding-left: 5px; + +} +input, textarea { + box-sizing: border-box; +} +textarea { + /* Horizontal resizing is pretty screwy-looking. */ + resize: vertical; +} +table#options{ + width: 100%; + font-size: 14px; + position: relative; + border-spacing: 0 23px; +} +.example { + font-size: 12px; + line-height: 16px; + color: #979ca0; + margin-left: 20px; +} +.info { + margin-left: 0px; +} +.caption { + margin-right: 10px; + min-width: 130px; + padding-top: 3px; + vertical-align: top; +} +td { padding: 0; } +div#exampleKeyMapping { + margin-left: 10px; + margin-top: 5px; +} +input#linkHintCharacters { + width: 100%; +} +input#linkHintNumbers { + width: 100%; +} +input#linkHintCharacters { + width: 100%; +} +input#scrollStepSize { + width: 40px; + margin-right: 3px; +} +textarea#userDefinedLinkHintCss, textarea#keyMappings, textarea#searchEngines { + width: 100%;; + min-height: 130px; + white-space: nowrap; +} +input#previousPatterns, input#nextPatterns { + width: 100%; +} +input#newTabUrl { + width: 100%; +} +input#searchUrl { + width: 100%; +} +#status { + margin-left: 10px; + font-size: 80%; +} +/* Make the caption in the settings table as small as possible, to pull the other fields to the right. */ +.caption { + width: 1px; + white-space: nowrap; +} +#buttonsPanel { width: 100%; } +#advancedOptions { display: none; } +#advancedOptionsLink { line-height: 24px; } +.help { + position: absolute; + right: -320px; + width: 320px; +} +input:read-only { + background-color: #eee; + color: #666; + pointer-events: none; + -webkit-user-select: none; +} +input[type="text"], textarea { + border: 1px solid #bfbfbf; + border-radius: 2px; + color: #444; + font: inherit; + padding: 3px; +} +button:focus, input[type="text"]:focus, textarea:focus { + -webkit-transition: border-color 200ms; + border-color: #4d90fe; + outline: none; +} +/* Boolean options have a tighter form representation than text options. */ +td.booleanOption { font-size: 12px; } +/* Ids and classes for rendering exclusionRules */ +#exclusionScrollBox { + overflow: scroll; + overflow-x: hidden; + overflow-y: auto; + /* Each exclusion rule is about 30px, so this allows 7 before scrolling */ + max-height: 215px; + min-height: 75px; + border-radius: 2px; + color: #444; + width: 100% +} +#exclusionRules { + width: 100%; +} +.exclusionRulePassKeys { + width: 33%; +} +.exclusionRemoveButton { + width: 1px; /* 1px; smaller than the button itself. */ +} +.exclusionRemoveButtonButton { + border: none; + background-color: #fff; + color: #979ca0; +} +.exclusionRemoveButtonButton:hover { + color: #444; +} +input.pattern, input.passKeys, .exclusionHeaderText { + width: 100%; + font-family: Consolas, "Liberation Mono", Courier, monospace; + font-size: 14px; +} +.exclusionHeaderText { + padding-left: 3px; + color: #979ca0; +} +#exclusionAddButton { + float: right; + margin-right: 0px; + margin-top: 5px; +} +#footer { + background: #f5f5f5; + border-top: 1px solid #979ca0; + position: fixed; + bottom: 0px; + z-index: 10; +} +#footer, #footerTable, #footerTableData { + width: 100%; +} +#endSpace { + /* Leave space for the fixed footer. */ + min-height: 30px; + max-height: 30px; +} +#helpText { + font-size: 12px; +} +#saveOptionsTableData { + float: right; +} +#saveOptions { + white-space: nowrap; + width: 110px; +} diff --git a/pages/options.html b/pages/options.html index 4f037ba5..d37646c4 100644 --- a/pages/options.html +++ b/pages/options.html @@ -1,218 +1,9 @@ <html> <head> <title>Vimium Options</title> + <link rel="stylesheet" type="text/css" href="options.css"> <script src="content_script_loader.js"></script> - <style type="text/css" media="screen"> - body { - font: 14px "DejaVu Sans", "Arial", sans-serif; - color: #303942; - width: 680px; - margin: 0 auto; - } - a, a:visited { color: #15c; } - a:active { color: #052577; } - div#wrapper { width: 500px; } - header { - font-size: 18px; - font-weight: normal; - border-bottom: 1px solid #eee; - padding: 20px 0 15px 0; - width: 100%; - } - button { - -webkit-user-select: none; - -webkit-appearance: none; - background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede); - border: 1px solid rgba(0, 0, 0, 0.25); - border-radius: 2px; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75); - color: #444; - font: inherit; - text-shadow: 0 1px 0 #f0f0f0; - height: 24px; - font-size: 12px; - padding: 0 10px; - } - button:hover { - background-image: -webkit-linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0); - border-color: rgba(0, 0, 0, 0.3); - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12), inset 0 1px 2px rgba(255, 255, 255, 0.95); - color: black; - } - button:active { - background-image: -webkit-linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7); - box-shadow: none; - text-shadow: none; - } - button[disabled], button[disabled]:hover, button[disabled]:active { - background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede); - border: 1px solid rgba(0, 0, 0, 0.25); - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75); - text-shadow: 0 1px 0 #f0f0f0; - color: #888; - } - input[type="checkbox"] { - -webkit-user-select: none; - } - label:hover { - color: black; - } - pre, code, .code { - font-family: Consolas, "Liberation Mono", Courier, monospace; - } - pre { - margin: 5px; - border-left: 1px solid #eee; - padding-left: 5px; - - } - input, textarea { - box-sizing: border-box; - } - textarea { - /* Horizontal resizing is pretty screwy-looking. */ - resize: vertical; - } - table#options{ - width: 100%; - font-size: 14px; - position: relative; - border-spacing: 0 23px; - } - .example { - font-size: 12px; - line-height: 16px; - color: #979ca0; - margin-left: 20px; - } - .info { - margin-left: 0px; - } - .caption { - margin-right: 10px; - min-width: 130px; - padding-top: 3px; - vertical-align: top; - } - td { padding: 0; } - div#exampleKeyMapping { - margin-left: 10px; - margin-top: 5px; - } - input#linkHintCharacters { - width: 100%; - } - input#linkHintNumbers { - width: 100%; - } - input#linkHintCharacters { - width: 100%; - } - input#scrollStepSize { - width: 40px; - margin-right: 3px; - } - textarea#userDefinedLinkHintCss { - width: 100%;; - min-height: 130px; - white-space: nowrap; - } - textarea#keyMappings { - width: 100%; - min-height: 135px; - white-space: nowrap; - } - textarea#searchEngines { - width: 100%; - min-height: 135px; - white-space: nowrap; - } - input#previousPatterns, input#nextPatterns { - width: 100%; - } - input#newTabUrl { - width: 100%; - } - input#searchUrl { - width: 100%; - } - #status { - margin-left: 10px; - font-size: 80%; - } - /* Make the caption in the settings table as small as possible, to pull the other fields to the right. */ - td:first-child { - width: 1px; - white-space: nowrap; - } - #buttonsPanel { width: 100%; } - #advancedOptions { display: none; } - #advancedOptionsLink { line-height: 24px; } - #saveOptions { float: right; } - #saveOptions { margin-right: 0; } - #showHelpDialogMessage { - width: 100%; - color: #979ca0; - font-size: 12px; - } - .help { - position: absolute; - right: -320px; - width: 320px; - } - input:read-only { - background-color: #eee; - color: #666; - pointer-events: none; - -webkit-user-select: none; - } - input[type="text"], textarea { - border: 1px solid #bfbfbf; - border-radius: 2px; - color: #444; - font: inherit; - padding: 3px; - } - button:focus, input[type="text"]:focus, textarea:focus { - -webkit-transition: border-color 200ms; - border-color: #4d90fe; - outline: none; - } - /* Boolean options have a tighter form representation than text options. */ - td.booleanOption { font-size: 12px; } - footer { - padding: 15px 0; - border-top: 1px solid #eee; - } - /* Ids and classes for rendering exclusionRules */ - #exclusionScrollBox { - overflow: scroll; - overflow-x: hidden; - overflow-y: auto; - height: 170px; - border: 1px solid #bfbfbf; - border-radius: 2px; - color: #444; - } - input.pattern, input.passKeys { - font-family: Consolas, "Liberation Mono", Courier, monospace; - font-size: 14px; - } - .pattern { - width: 250px; - } - .passKeys { - width: 120px; - } - #exclusionAddButton { - float: right; - margin-top: 5px; - margin-right: 0px; - } - </style> - - <script type="text/javascript" src="options.js"></script> - + <script type="text/javascript" src="options.js"></script> </head> <body> @@ -224,25 +15,16 @@ <td> <div class="help"> <div class="example"> - The left column contains URL patterns. Vimium will be wholly or partially disabled for URLs - matching these patterns. Patterns are Javascript regular expressions. Additionally, the - character "*" matches any zero or more characters. + Wholly or partially disable Vimium. "Patterns" are URL regular expressions; + additionally, "*" matches any zero or more characters. <br/><br/> - The right column contains keys which Vimium would would normally handle, but which should - instead be passed through to the underlying web page (for pages matching the - pattern). If left empty, then Vimium will be wholly disabled. + If "Keys" is left empty, then vimium is wholly disabled. + Otherwise, just the listed keys are disabled (they are passed through). </div> </div> <div> <div id="exclusionScrollBox"> - <table id="exclusionRules"></table> - <template id="exclusionRuleTemplate"> - <tr> - <td><input/ type="text" class="pattern" placeholder="URL pattern"></td> - <td><input/ type="text" class="passKeys" placeholder="Exclude keys"></td> - <td><input/ type="button" class="exclusionRemoveButton" tabindex = "-1" value="✖"></td> - </tr> - </template> + <!-- Populated from exclusions.html by options.coffee. --> </div> <button id="exclusionAddButton">Add Rule</button> </div> @@ -272,24 +54,30 @@ unmapAll <td verticalAlign="top"> <div class="help"> <div class="example"> - This adds search-engine shortcuts to the Vomnibar.<br/><br/> - The format is:<br/> - <pre>your-keyword: http://the-site.com/?q=%s</pre> - %s will be replaced with your search terms.<br/> - Lines which start with "#" are comments. + Add search-engine shortcuts to the Vomnibar. Format:<br/> + <pre> +a: http://a.com/?q=%s +b: http://b.com/?q=%s description +" this is a comment +# this is also a comment</pre> + %s is replaced with the search terms. </div> </div> <textarea id="searchEngines"></textarea> </td> </tr> <tr> - <td><a href="#" id="advancedOptionsLink">Show advanced options…</a></td> - <td><button id="saveOptions" disabled="true">Save Options</button></td> + <td colspan="2"><a href="#" id="advancedOptionsLink">Show advanced options…</a></td> </tr> <tbody id='advancedOptions'> <tr> <td class="caption">Scroll step size</td> <td> + <div class="help"> + <div class="example"> + The size for basic movements (usually j/k/h/l). + </div> + </div> <input id="scrollStepSize" type="number" />px </td> </tr> @@ -306,7 +94,7 @@ unmapAll </td> </tr> <tr> - <td class="caption">Numbers used<br/> for filtered link hints</td> + <td class="caption">Numbers used<br/> for link hints</td> <td verticalAlign="top"> <div class="help"> <div class="example"> @@ -318,6 +106,15 @@ unmapAll </td> </tr> <tr> + <td class="caption" verticalAlign="top">Miscellaneous<br/>options</td> + <td verticalAlign="top" class="booleanOption"> + <label> + <input id="smoothScroll" type="checkbox"/> + Use smooth scrolling + </label> + </td> + </tr> + <tr> <td class="caption"></td> <td verticalAlign="top" class="booleanOption"> <div class="help"> @@ -355,7 +152,7 @@ unmapAll </div> <label> <input id="regexFindMode" type="checkbox"/> - Treat find queries as regular expressions. + Treat find queries as regular expressions </label> </td> </tr> @@ -424,14 +221,28 @@ unmapAll </tr> </tbody> </table> + </div> - <br/> + <!-- Some extra space which is hidden underneath the footer. --> + <div id="endSpace"/> - <footer id="showHelpDialogMessage"> - Type <strong>?</strong> to show the Vimium help dialog. - <br/> - Type <strong>Ctrl-Enter</strong> in text inputs to save all options. - </footer> + <div id="footer"> + <div id="footerWrapper"> + <table id="footerTable"> + <tr> + <td id="footerTableData"> + <span id="helpText"> + Type <strong>?</strong> to show the Vimium help dialog. + <br/> + Type <strong>Ctrl-Enter</strong> to save <i>all</i> options. + </span> + </td> + <td id="saveOptionsTableData"> + <button id="saveOptions" disabled="true">No Changes</button> + </td> + </tr> + </table> + </div> </div> </body> </html> diff --git a/pages/popup.coffee b/pages/popup.coffee deleted file mode 100644 index 99a4eb87..00000000 --- a/pages/popup.coffee +++ /dev/null @@ -1,110 +0,0 @@ - -originalRule = undefined -originalPattern = undefined -originalPassKeys = undefined - -generateDefaultPattern = (url) -> - if /^https?:\/\/./.test url - # The common use case is to disable Vimium at the domain level. - # Generate "https?://www.example.com/*" from "http://www.example.com/path/to/page.html". - "https?:/" + url.split("/",3)[1..].join("/") + "/*" - else if /^[a-z]{3,}:\/\/./.test url - # Anything else which seems to be a URL. - url.split("/",3).join("/") + "/*" - else - url + "*" - -reset = (initialize=false) -> - document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html") - chrome.tabs.getSelected null, (tab) -> - isEnabled = chrome.extension.getBackgroundPage().isEnabledForUrl(url: tab.url) - # Check if we have an existing exclusing rule for this page. - if isEnabled.rule - originalRule = isEnabled.rule - originalPattern = originalRule.pattern - originalPassKeys = originalRule.passKeys - else - originalRule = null - originalPattern = generateDefaultPattern tab.url - originalPassKeys = "" - patternElement = document.getElementById("popupPattern") - passKeysElement = document.getElementById("popupPassKeys") - patternElement.value = originalPattern - passKeysElement.value = originalPassKeys - if initialize - # Activate <Ctrl-Enter> to save. - for element in [ patternElement, passKeysElement ] - element.addEventListener "keyup", (event) -> - if event.ctrlKey and event.keyCode == 13 - addExclusionRule() - window.close() - element.addEventListener "focus", -> document.getElementById("helpText").style.display = "block" - element.addEventListener "blur", -> document.getElementById("helpText").style.display = "none" - # Focus passkeys with cursor at the end (but only when creating popup). - passKeysElement.focus() - passKeysElement.setSelectionRange(passKeysElement.value.length, passKeysElement.value.length) - onChange() - -onChange = -> - # As the text in the popup's input elements is changed, update the the popup's buttons accordingly. - # Aditionally, enable and disable those buttons as appropriate. - pattern = document.getElementById("popupPattern").value.trim() - passKeys = document.getElementById("popupPassKeys").value.trim() - popupExclude = document.getElementById("popupExclude") - - document.getElementById("popupRemove").disabled = - not (originalRule and pattern == originalPattern) - - if originalRule and pattern == originalPattern and passKeys == originalPassKeys - popupExclude.disabled = true - popupExclude.value = "Update Rule" - - else if originalRule and pattern == originalPattern - popupExclude.disabled = false - popupExclude.value = "Update Rule" - - else if originalRule - popupExclude.disabled = false - popupExclude.value = "Add Rule" - - else if pattern - popupExclude.disabled = false - popupExclude.value = "Add Rule" - - else - popupExclude.disabled = true - popupExclude.value = "Add Rule" - -showMessage = do -> - timer = null - - hideConfirmationMessage = -> - document.getElementById("confirmationMessage").setAttribute "style", "display: none" - timer = null - - (message) -> - document.getElementById("confirmationMessage").setAttribute "style", "display: inline-block" - document.getElementById("confirmationMessage").innerHTML = message - clearTimeout(timer) if timer - timer = setTimeout(hideConfirmationMessage,2000) - -addExclusionRule = -> - pattern = document.getElementById("popupPattern").value.trim() - passKeys = document.getElementById("popupPassKeys").value.trim() - chrome.extension.getBackgroundPage().addExclusionRule pattern, passKeys - showMessage("Updated.") - reset() - -removeExclusionRule = -> - pattern = document.getElementById("popupPattern").value.trim() - chrome.extension.getBackgroundPage().removeExclusionRule pattern - showMessage("Removed.") - reset() - -document.addEventListener "DOMContentLoaded", -> - document.getElementById("popupExclude").addEventListener "click", addExclusionRule, false - document.getElementById("popupRemove").addEventListener "click", removeExclusionRule, false - for field in ["popupPattern", "popupPassKeys"] - for event in ["input", "change"] - document.getElementById(field).addEventListener event, onChange, false - reset true diff --git a/pages/popup.html b/pages/popup.html index 691414f2..c7e2fd6f 100644 --- a/pages/popup.html +++ b/pages/popup.html @@ -1,85 +1,82 @@ <html> <head> + <link rel="stylesheet" type="text/css" href="options.css"> <style> * { margin: 0px; padding: 0px; } - #vimiumPopup { width: 400px; } - - #excludeControls { - padding: 10px; + #helpText, #optionsLink, #state { + font-family : "Helvetica Neue", "Helvetica", "Arial", sans-serif; + font-size: 12px; } - #popupPattern, #popupPassKeys { - margin: 5px; - width: 330px; - /* Match the corresponding font and font size used on the options page. */ - /* TODO (smblott): Match other styles from the options page. */ - font-family: Consolas, "Liberation Mono", Courier, monospace; - font-size: 14px; + #helpText, #stateLine, #state { color: #979ca0; } + #exclusionAddButton { width: 80px; } + + #saveOptions { + margin-top: 5px; /* Match #exclusionAddButton */ + margin-left: 5px; + float: right; } - #confirmationMessage { - display: inline-block; - width: 18px; - height: 13px; - background: url(icons/check.png) 3px 2px no-repeat; - display: none; + #state { + padding-left: 5px; + background: #f5f5f5; + width: 100%; + border-bottom: 1px solid #979ca0; + margin: 0px; } - #popupRemove { margin: 5px; } - #popupExclude { margin: 5px; } + /* These are overridden from options.css. */ - #popupMenu ul { - list-style: none; - } + #endSpace, #footerWrapper { width: 500px; } + #footerWrapper { margin-left: 0px; } - #popupMenu li, #popupMenu a:active, #popupMenu a:visited { - color: #3F6EC2; - display: block; - border-top: 1px solid #DDDDDD; - padding: 3px; - padding-left: 10px; + /* Make exclusionScrollBox smaller than on the options page, because there are likely to be fewer + matching rules, and the popup obscures the underlying page. + */ + #exclusionScrollBox { + max-height: 124px; + min-height: 124px; } - #popupMenu a:hover { - background: #EEEEEE; + #endSpace { /* Leave space for the fixed footer. */ + min-height: 40px; + max-height: 40px; } - #optionsLink { - font-family : "Helvetica Neue", "Helvetica", "Arial", sans-serif; - font-size: 12px; - float: right; - } - #helpText { - color: #979ca0; - font-family : "Helvetica Neue", "Helvetica", "Arial", sans-serif; - font-size: 12px; - float: left; - display: none; - } </style> - <script src="popup.js"></script> + <script src="options.js"></script> </head> <body> - <div id="vimiumPopup"> - <div id="excludeControls"> - <input id="popupPattern" placeholder="Pattern against which to match URLs..." type="text" /><br/> - <input id="popupPassKeys" placeholder="Only exclude these keys..." type="text" /><br/> - <input id="popupRemove" type="button" value="Remove Rule" /> - <input id="popupExclude" type="button" value="Add or Update Rule" /> - <span id="confirmationMessage">Text is added in popup.coffee.</span> - </div> + <div id="state"></div> + + <div id="exclusionScrollBox"> + <!-- Populated from exclusions.html by options.coffee. --> + </div> + + <!-- Some extra space which is hidden underneath the footer. --> + <div id="endSpace"/> - <div id="popupMenu"> - <ul> - <li> - <span id="helpText">Type <strong>Ctrl-ENTER</strong> to save and close.</span> - <a id="optionsLink" target="_blank">Options</a> - </li> - </ul> + <div id="footer"> + <div id="footerWrapper"> + <table> + <tr> + <td id="stateLine" style="width: 99%"> + <span id="helpText">These are the rules matching this page.</span> + <br/> + <a id="optionsLink" target="_blank" tabindex="99999">Options</a> + </td> + <td valign="top"> + <button id="exclusionAddButton">Add Rule</button> + </td> + <td valign="top"> + <button id="saveOptions" disabled="true">No Changes</button> + </td> + </tr> + </table> </div> </div> </body> diff --git a/pages/ui_component_server.coffee b/pages/ui_component_server.coffee new file mode 100644 index 00000000..8b43095b --- /dev/null +++ b/pages/ui_component_server.coffee @@ -0,0 +1,27 @@ + +# Fetch the Vimium secret, register the port recieved from the parent window, and stop listening for messages +# on the window object. vimiumSecret is accessible only within the current instantion of Vimium. So a +# malicious host page trying to register its own port can do no better than guessing. +registerPort = (event) -> + chrome.storage.local.get "vimiumSecret", ({vimiumSecret: secret}) -> + return unless event.source == window.parent and event.data == secret + UIComponentServer.portOpen event.ports[0] + window.removeEventListener "message", registerPort + +window.addEventListener "message", registerPort + +UIComponentServer = + ownerPagePort: null + handleMessage: null + + portOpen: (@ownerPagePort) -> + @ownerPagePort.onmessage = (event) => + @handleMessage event if @handleMessage + + registerHandler: (@handleMessage) -> + + postMessage: (message) -> + @ownerPagePort.postMessage message if @ownerPagePort + +root = exports ? window +root.UIComponentServer = UIComponentServer diff --git a/pages/vomnibar.coffee b/pages/vomnibar.coffee new file mode 100644 index 00000000..0ade7f0e --- /dev/null +++ b/pages/vomnibar.coffee @@ -0,0 +1,235 @@ +# +# This controls the contents of the Vomnibar iframe. We use an iframe to avoid changing the selection on the +# page (useful for bookmarklets), ensure that the Vomnibar style is unaffected by the page, and simplify key +# handling in vimium_frontend.coffee +# +Vomnibar = + vomnibarUI: null # the dialog instance for this window + getUI: -> @vomnibarUI + completers: {} + + getCompleter: (name) -> + if (!(name of @completers)) + @completers[name] = new BackgroundCompleter(name) + @completers[name] + + # + # Activate the Vomnibox. + # + activate: (userOptions) -> + options = + completer: "omni" + query: "" + newTab: false + selectFirst: false + extend options, userOptions + + options.refreshInterval = switch options.completer + when "omni" then 100 + else 0 + + completer = @getCompleter(options.completer) + @vomnibarUI ?= new VomnibarUI() + completer.refresh() + @vomnibarUI.setInitialSelectionValue(if options.selectFirst then 0 else -1) + @vomnibarUI.setCompleter(completer) + @vomnibarUI.setRefreshInterval(options.refreshInterval) + @vomnibarUI.setForceNewTab(options.newTab) + @vomnibarUI.setQuery(options.query) + @vomnibarUI.update() + +class VomnibarUI + constructor: -> + @refreshInterval = 0 + @initDom() + + setQuery: (query) -> @input.value = query + + setInitialSelectionValue: (initialSelectionValue) -> + @initialSelectionValue = initialSelectionValue + + setCompleter: (completer) -> + @completer = completer + @reset() + @update(true) + + setRefreshInterval: (refreshInterval) -> @refreshInterval = refreshInterval + + setForceNewTab: (forceNewTab) -> @forceNewTab = forceNewTab + + hide: -> + UIComponentServer.postMessage "hide" + @reset() + + reset: -> + @completionList.style.display = "" + @input.value = "" + @updateTimer = null + @completions = [] + @selection = @initialSelectionValue + + updateSelection: -> + # We have taken the option to add some global state here (previousCompletionType) to tell if a search + # item has just appeared or disappeared, if that happens we either set the initialSelectionValue to 0 or 1 + # I feel that this approach is cleaner than bubbling the state up from the suggestion level + # so we just inspect it afterwards + if @completions[0] + if @previousCompletionType != "search" && @completions[0].type == "search" + @selection = 0 + else if @previousCompletionType == "search" && @completions[0].type != "search" + @selection = -1 + for i in [0...@completionList.children.length] + @completionList.children[i].className = (if i == @selection then "vomnibarSelected" else "") + @previousCompletionType = @completions[0].type if @completions[0] + + # + # Returns the user's action ("up", "down", "enter", "dismiss" or null) based on their keypress. + # We support the arrow keys and other shortcuts for moving, so this method hides that complexity. + # + actionFromKeyEvent: (event) -> + key = KeyboardUtils.getKeyChar(event) + if (KeyboardUtils.isEscape(event)) + return "dismiss" + else if (key == "up" || + (event.shiftKey && event.keyCode == keyCodes.tab) || + (event.ctrlKey && (key == "k" || key == "p"))) + return "up" + else if (key == "down" || + (event.keyCode == keyCodes.tab && !event.shiftKey) || + (event.ctrlKey && (key == "j" || key == "n"))) + return "down" + else if (event.keyCode == keyCodes.enter) + return "enter" + + onKeydown: (event) => + action = @actionFromKeyEvent(event) + return true unless action # pass through + + openInNewTab = @forceNewTab || + (event.shiftKey || event.ctrlKey || KeyboardUtils.isPrimaryModifierKey(event)) + if (action == "dismiss") + @hide() + else if (action == "up") + @selection -= 1 + @selection = @completions.length - 1 if @selection < @initialSelectionValue + @updateSelection() + else if (action == "down") + @selection += 1 + @selection = @initialSelectionValue if @selection == @completions.length + @updateSelection() + else if (action == "enter") + # If they type something and hit enter without selecting a completion from our list of suggestions, + # try to open their query as a URL directly. If it doesn't look like a URL, we will search using + # google. + if (@selection == -1) + query = @input.value.trim() + # <Enter> on an empty vomnibar is a no-op. + return unless 0 < query.length + @hide() + chrome.runtime.sendMessage({ + handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" + url: query }) + else + @update true, => + # Shift+Enter will open the result in a new tab instead of the current tab. + @completions[@selection].performAction(openInNewTab) + @hide() + + # It seems like we have to manually suppress the event here and still return true. + event.stopImmediatePropagation() + event.preventDefault() + true + + updateCompletions: (callback) -> + query = @input.value.trim() + + @completer.filter query, (completions) => + @completions = completions + @populateUiWithCompletions(completions) + callback() if callback + + populateUiWithCompletions: (completions) -> + # update completion list with the new data + @completionList.innerHTML = completions.map((completion) -> "<li>#{completion.html}</li>").join("") + @completionList.style.display = if completions.length > 0 then "block" else "" + @selection = Math.min(Math.max(@initialSelectionValue, @selection), @completions.length - 1) + @updateSelection() + + update: (updateSynchronously, callback) => + if (updateSynchronously) + # cancel scheduled update + if (@updateTimer != null) + window.clearTimeout(@updateTimer) + @updateCompletions(callback) + else if (@updateTimer != null) + # an update is already scheduled, don't do anything + return + else + # always update asynchronously for better user experience and to take some load off the CPU + # (not every keystroke will cause a dedicated update) + @updateTimer = setTimeout(=> + @updateCompletions(callback) + @updateTimer = null + @refreshInterval) + + @input.focus() + + initDom: -> + @box = document.getElementById("vomnibar") + + @input = @box.querySelector("input") + @input.addEventListener "input", @update + @input.addEventListener "keydown", @onKeydown + @completionList = @box.querySelector("ul") + @completionList.style.display = "" + + window.addEventListener "focus", => @input.focus() + +# +# Sends filter and refresh requests to a Vomnibox completer on the background page. +# +class BackgroundCompleter + # - name: The background page completer that you want to interface with. Either "omni", "tabs", or + # "bookmarks". */ + constructor: (@name) -> + @filterPort = chrome.runtime.connect({ name: "filterCompleter" }) + + refresh: -> chrome.runtime.sendMessage({ handler: "refreshCompleter", name: @name }) + + filter: (query, callback) -> + id = Utils.createUniqueId() + @filterPort.onMessage.addListener (msg) => + @filterPort.onMessage.removeListener(arguments.callee) + # The result objects coming from the background page will be of the form: + # { html: "", type: "", url: "" } + # type will be one of [tab, bookmark, history, domain]. + results = msg.results.map (result) -> + functionToCall = if (result.type == "tab") + BackgroundCompleter.completionActions.switchToTab.curry(result.tabId) + else + BackgroundCompleter.completionActions.navigateToUrl.curry(result.url) + result.performAction = functionToCall + result + callback(results) + + @filterPort.postMessage({ id: id, name: @name, query: query }) + +extend BackgroundCompleter, + # + # These are the actions we can perform when the user selects a result in the Vomnibox. + # + completionActions: + navigateToUrl: (url, openInNewTab) -> + # If the URL is a bookmarklet prefixed with javascript:, we shouldn't open that in a new tab. + openInNewTab = false if url.startsWith("javascript:") + chrome.runtime.sendMessage( + handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" + url: url, + selected: openInNewTab) + + switchToTab: (tabId) -> chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: tabId }) + +UIComponentServer.registerHandler (event) -> Vomnibar.activate event.data + +root = exports ? window +root.Vomnibar = Vomnibar diff --git a/pages/vomnibar.css b/pages/vomnibar.css new file mode 100644 index 00000000..2042a6c4 --- /dev/null +++ b/pages/vomnibar.css @@ -0,0 +1,136 @@ + +/* Vomnibar CSS */ + +#vomnibar ol, #vomnibar ul { + list-style: none; + display: none; +} + +#vomnibar { + display: block; + position: fixed; + width: calc(100% - 20px); /* adjusted to keep border radius and box-shadow visible*/ + /*min-width: 400px; + top: 70px; + left: 50%;*/ + top: 8px; + left: 8px; + /*margin: 0 0 0 -40%;*/ + font-family: sans-serif; + + background: #F1F1F1; + text-align: left; + border-radius: 4px; + box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8); + border: 1px solid #aaa; + /* One less than hint markers and the help dialog. */ + z-index: 2147483646; +} + +#vomnibar input { + color: #000; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 20px; + height: 34px; + margin-bottom: 0; + padding: 4px; + background-color: white; + border-radius: 3px; + border: 1px solid #E8E8E8; + box-shadow: #444 0px 0px 1px; + width: 100%; + outline: none; + box-sizing: border-box; +} + +#vomnibar .vomnibarSearchArea { + display: block; + padding: 10px; + background-color: #F1F1F1; + border-radius: 4px 4px 0 0; + border-bottom: 1px solid #C6C9CE; +} + +#vomnibar ul { + background-color: white; + border-radius: 0 0 4px 4px; + list-style: none; + padding: 10px 0; + padding-top: 0; +} + +#vomnibar li { + border-bottom: 1px solid #ddd; + line-height: 1.1em; + padding: 7px 10px; + font-size: 16px; + color: black; + position: relative; + display: list-item; + margin: auto; +} + +#vomnibar li:last-of-type { + border-bottom: none; +} + +#vomnibar li .vomnibarTopHalf, #vomnibar li .vomnibarBottomHalf { + display: block; + overflow: hidden; +} + +#vomnibar li .vomnibarBottomHalf { + font-size: 15px; + margin-top: 3px; + padding: 2px 0; +} + +#vomnibar li .vomnibarIcon { + background-position-y: center; + background-size: 16px; + background-repeat: no-repeat; + padding-left: 20px; +} + +#vomnibar li .vomnibarSource { + color: #777; + margin-right: 4px; +} +#vomnibar li .vomnibarRelevancy { + position: absolute; + right: 0; + top: 0; + padding: 5px; + background-color: white; + color: black; + font-family: monospace; + width: 100px; + overflow: hidden; +} + +#vomnibar li .vomnibarUrl { + white-space: nowrap; + color: #224684; +} + +#vomnibar li .vomnibarMatch { + font-weight: bold; + color: black; +} + +#vomnibar li em, #vomnibar li .vomnibarTitle { + color: black; + margin-left: 4px; + font-weight: normal; +} +#vomnibar li em { font-style: italic; } +#vomnibar li em .vomnibarMatch, #vomnibar li .vomnibarTitle .vomnibarMatch { + color: #333; + text-decoration: underline; +} + +#vomnibar li.vomnibarSelected { + background-color: #BBCEE9; + font-weight: normal; +} + diff --git a/pages/vomnibar.html b/pages/vomnibar.html new file mode 100644 index 00000000..2ca463d0 --- /dev/null +++ b/pages/vomnibar.html @@ -0,0 +1,22 @@ +<html> + <head> + <title>Vomnibar</title> + <script type="text/javascript" src="../lib/utils.js"></script> + <script type="text/javascript" src="../lib/keyboard_utils.js"></script> + <script type="text/javascript" src="../lib/dom_utils.js"></script> + <script type="text/javascript" src="../lib/handler_stack.js"></script> + <script type="text/javascript" src="../lib/clipboard.js"></script> + <script type="text/javascript" src="ui_component_server.js"></script> + <script type="text/javascript" src="vomnibar.js"></script> + <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> + <link rel="stylesheet" type="text/css" href="vomnibar.css" /> + </head> + <body> + <div id="vomnibar" class="vimiumReset"> + <div class="vimiumReset vomnibarSearchArea"> + <input type="text" class="vimiumReset"> + </div> + <ul class="vimiumReset"></ul> + </div> + </body> +</html> diff --git a/test_harnesses/vomnibar.html b/test_harnesses/vomnibar.html index 4d50e749..820210b0 100644 --- a/test_harnesses/vomnibar.html +++ b/test_harnesses/vomnibar.html @@ -11,7 +11,7 @@ <script type="text/javascript" src="../lib/keyboard_utils.js"></script> <script type="text/javascript" src="../lib/dom_utils.js"></script> <script src="https://github.com/ooyala/livecss/raw/master/livecss.js"></script> - <script type="text/javascript" src="../content_scripts/vomnibar.js"></script> + <script type="text/javascript" src="../pages/vomnibar.js"></script> <link rel="stylesheet" type="text/css" href="../vimium.css" /> <script> function setup() { diff --git a/tests/dom_tests/chrome.coffee b/tests/dom_tests/chrome.coffee index 7f99e27f..ad4ae74b 100644 --- a/tests/dom_tests/chrome.coffee +++ b/tests/dom_tests/chrome.coffee @@ -10,6 +10,9 @@ root.chrome = { onMessage: { addListener: -> } + onDisconnect: { + addListener: -> + } postMessage: -> } onMessage: { @@ -17,5 +20,10 @@ root.chrome = { } sendMessage: -> getManifest: -> + getURL: (url) -> "../../#{url}" } + storage: + local: + get: -> + set: -> } diff --git a/tests/dom_tests/dom_tests.coffee b/tests/dom_tests/dom_tests.coffee index 4a61877c..a4713a72 100644 --- a/tests/dom_tests/dom_tests.coffee +++ b/tests/dom_tests/dom_tests.coffee @@ -8,10 +8,23 @@ mockKeyboardEvent = (keyChar) -> event.charCode = (if keyCodes[keyChar] isnt undefined then keyCodes[keyChar] else keyChar.charCodeAt(0)) event.keyIdentifier = "U+00" + event.charCode.toString(16) event.keyCode = event.charCode - event.stopImmediatePropagation = -> - event.preventDefault = -> + event.stopImmediatePropagation = -> @suppressed = true + event.preventDefault = -> @suppressed = true event +# Some of these tests have side effects on the handler stack and active mode. Therefore, we take backups and +# restore them on tear down. +backupStackState = -> + Mode.backup = Mode.modes[..] + InsertMode.permanentInstance.exit() + handlerStack.backup = handlerStack.stack[..] +restoreStackState = -> + for mode in Mode.modes + mode.exit() unless mode in Mode.backup + Mode.modes = Mode.backup + InsertMode.permanentInstance.exit() + handlerStack.stack = handlerStack.backup + # # Retrieve the hint markers as an array object. # @@ -170,20 +183,36 @@ context "Input focus", testContent = "<input type='text' id='first'/><input style='display:none;' id='second'/> <input type='password' id='third' value='some value'/>" document.getElementById("test-div").innerHTML = testContent + backupStackState() tearDown -> document.getElementById("test-div").innerHTML = "" + restoreStackState() should "focus the right element", -> focusInput 1 assert.equal "first", document.activeElement.id - # deactivate the tabbing mode and its overlays - handlerStack.bubbleEvent 'keydown', mockKeyboardEvent("A") focusInput 100 assert.equal "third", document.activeElement.id handlerStack.bubbleEvent 'keydown', mockKeyboardEvent("A") + # This is the same as above, but also verifies that focusInput activates insert mode. + should "activate insert mode", -> + focusInput 1 + handlerStack.bubbleEvent 'focus', target: document.activeElement + assert.isTrue InsertMode.permanentInstance.isActive() + + focusInput 100 + handlerStack.bubbleEvent 'focus', target: document. activeElement + assert.isTrue InsertMode.permanentInstance.isActive() + + should "select the previously-focused input when count is 1", -> + focusInput 100 + handlerStack.bubbleEvent 'focus', target: document. activeElement + focusInput 1 + assert.equal "third", document.activeElement.id + # TODO: these find prev/next link tests could be refactored into unit tests which invoke a function which has # a tighter contract than goNext(), since they test minor aspects of goNext()'s link matching behavior, and we # don't need to construct external state many times over just to test that. @@ -243,9 +272,429 @@ context "Find prev / next links", goNext() assert.equal '#first', window.location.hash - createLinks = (n) -> for i in [0...n] by 1 link = document.createElement("a") link.textContent = "test" document.getElementById("test-div").appendChild link + +# For these tests, we use "m" as a mapped key, "p" as a pass key, and "u" as an unmapped key. +context "Normal mode", + setup -> + document.activeElement?.blur() + backupStackState() + refreshCompletionKeys + completionKeys: "m" + + tearDown -> + restoreStackState() + + should "suppress mapped keys", -> + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "m" + handlerStack.bubbleEvent event, key + assert.isTrue key.suppressed + + should "not suppress unmapped keys", -> + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "u" + handlerStack.bubbleEvent event, key + assert.isFalse key.suppressed + +context "Passkeys mode", + setup -> + backupStackState() + refreshCompletionKeys + completionKeys: "mp" + + handlerStack.bubbleEvent "registerStateChange", + enabled: true + passKeys: "" + + handlerStack.bubbleEvent "registerKeyQueue", + keyQueue: "" + + tearDown -> + restoreStackState() + handlerStack.bubbleEvent "registerStateChange", + enabled: true + passKeys: "" + + handlerStack.bubbleEvent "registerKeyQueue", + keyQueue: "" + + should "not suppress passKeys", -> + # First check normal-mode key (just to verify the framework). + for k in [ "m", "p" ] + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "p" + handlerStack.bubbleEvent event, key + assert.isTrue key.suppressed + + # Install passKey. + handlerStack.bubbleEvent "registerStateChange", + enabled: true + passKeys: "p" + + # Then verify passKey. + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "p" + handlerStack.bubbleEvent event, key + assert.isFalse key.suppressed + + # And re-verify a mapped key. + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "m" + handlerStack.bubbleEvent event, key + assert.isTrue key.suppressed + + should "suppress passKeys with a non-empty keyQueue", -> + # Install passKey. + handlerStack.bubbleEvent "registerStateChange", + enabled: true + passKeys: "p" + + # First check the key is indeed not suppressed. + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "p" + handlerStack.bubbleEvent event, key + assert.isFalse key.suppressed + + handlerStack.bubbleEvent "registerKeyQueue", + keyQueue: "1" + + # Now verify that the key is suppressed. + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "p" + handlerStack.bubbleEvent event, key + assert.isTrue key.suppressed + +context "Insert mode", + setup -> + document.activeElement?.blur() + backupStackState() + refreshCompletionKeys + completionKeys: "m" + + tearDown -> + backupStackState() + + should "not suppress mapped keys in insert mode", -> + # First verify normal-mode key (just to verify the framework). + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "m" + handlerStack.bubbleEvent event, key + assert.isTrue key.suppressed + + # Install insert mode. + insertMode = new InsertMode + global: true + + # Then verify insert mode. + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "m" + handlerStack.bubbleEvent event, key + assert.isFalse key.suppressed + + insertMode.exit() + + # Then verify that insert mode has been successfully removed. + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "m" + handlerStack.bubbleEvent event, key + assert.isTrue key.suppressed + +context "Triggering insert mode", + setup -> + document.activeElement?.blur() + backupStackState() + refreshCompletionKeys + completionKeys: "m" + + testContent = "<input type='text' id='first'/> + <input style='display:none;' id='second'/> + <input type='password' id='third' value='some value'/>" + document.getElementById("test-div").innerHTML = testContent + + tearDown -> + restoreStackState() + document.getElementById("test-div").innerHTML = "" + + should "trigger insert mode on focus of contentEditable elements", -> + handlerStack.bubbleEvent "focus", + target: + isContentEditable: true + + assert.isTrue Mode.top().name == "insert" and Mode.top().isActive() + + should "trigger insert mode on focus of text input", -> + document.getElementById("first").focus() + handlerStack.bubbleEvent "focus", { target: document.activeElement } + + assert.isTrue Mode.top().name == "insert" and Mode.top().isActive() + + should "trigger insert mode on focus of password input", -> + document.getElementById("third").focus() + handlerStack.bubbleEvent "focus", { target: document.activeElement } + + assert.isTrue Mode.top().name == "insert" and Mode.top().isActive() + + should "not handle suppressed events", -> + document.getElementById("first").focus() + handlerStack.bubbleEvent "focus", { target: document.activeElement } + assert.isTrue Mode.top().name == "insert" and Mode.top().isActive() + + for event in [ "keydown", "keypress", "keyup" ] + # Because "m" is mapped, we expect insert mode to ignore it, and normal mode to suppress it. + key = mockKeyboardEvent "m" + InsertMode.suppressEvent key + handlerStack.bubbleEvent event, key + assert.isTrue key.suppressed + + +context "Mode utilities", + setup -> + backupStackState() + refreshCompletionKeys + completionKeys: "m" + + testContent = "<input type='text' id='first'/> + <input style='display:none;' id='second'/> + <input type='password' id='third' value='some value'/>" + document.getElementById("test-div").innerHTML = testContent + + tearDown -> + restoreStackState() + document.getElementById("test-div").innerHTML = "" + + should "not have duplicate singletons", -> + count = 0 + + class Test extends Mode + constructor: -> + count += 1 + super + singleton: Test + + exit: -> + count -= 1 + super() + + assert.isTrue count == 0 + for [1..10] + mode = new Test(); assert.isTrue count == 1 + + mode.exit() + assert.isTrue count == 0 + + should "exit on escape", -> + escape = + keyCode: 27 + + new Mode + exitOnEscape: true + name: "test" + + assert.isTrue Mode.top().name == "test" + handlerStack.bubbleEvent "keydown", escape + assert.isTrue Mode.top().name != "test" + + should "not exit on escape if not enabled", -> + escape = + keyCode: 27 + keyIdentifier: "" + stopImmediatePropagation: -> + + new Mode + exitOnEscape: false + name: "test" + + assert.isTrue Mode.top().name == "test" + handlerStack.bubbleEvent "keydown", escape + assert.isTrue Mode.top().name == "test" + + should "exit on blur", -> + element = document.getElementById("first") + element.focus() + + new Mode + exitOnBlur: element + name: "test" + + assert.isTrue Mode.top().name == "test" + handlerStack.bubbleEvent "blur", { target: element } + assert.isTrue Mode.top().name != "test" + + should "not exit on blur if not enabled", -> + element = document.getElementById("first") + element.focus() + + new Mode + exitOnBlur: null + name: "test" + + assert.isTrue Mode.top().name == "test" + handlerStack.bubbleEvent "blur", { target: element } + assert.isTrue Mode.top().name == "test" + + should "register state change", -> + enabled = null + passKeys = null + + class Test extends Mode + constructor: -> + super + trackState: true + + registerStateChange: -> + enabled = @enabled + passKeys = @passKeys + + new Test() + handlerStack.bubbleEvent "registerStateChange", + enabled: "enabled" + passKeys: "passKeys" + assert.isTrue enabled == "enabled" + assert.isTrue passKeys == "passKeys" + + should "suppress printable keys", -> + element = document.getElementById("first") + element.focus() + handlerStack.bubbleEvent "focus", { target: document.activeElement } + + # Verify that a key is not suppressed. + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "u" + handlerStack.bubbleEvent event, key + assert.isFalse key.suppressed + + new PostFindMode {} + + # Verify that the key is now suppressed for keypress. + key = mockKeyboardEvent "u" + handlerStack.bubbleEvent "keypress", + extend key, + srcElement: element + assert.isTrue key.suppressed + + # Verify key is not suppressed with Control key. + key = mockKeyboardEvent "u" + handlerStack.bubbleEvent "keypress", + extend key, + srcElement: element + ctrlKey: true + assert.isFalse key.suppressed + + # Verify key is not suppressed with Meta key. + key = mockKeyboardEvent "u" + handlerStack.bubbleEvent "keypress", + extend key, + srcElement: element + metaKey: true + assert.isFalse key.suppressed + +context "PostFindMode", + setup -> + backupStackState() + refreshCompletionKeys + completionKeys: "m" + + testContent = "<input type='text' id='first'/> + <input style='display:none;' id='second'/> + <input type='password' id='third' value='some value'/>" + document.getElementById("test-div").innerHTML = testContent + + @escape = + keyCode: 27 + keyIdentifier: "" + stopImmediatePropagation: -> + preventDefault: -> + + @element = document.getElementById("first") + @element.focus() + handlerStack.bubbleEvent "focus", { target: document.activeElement } + + tearDown -> + restoreStackState() + document.getElementById("test-div").innerHTML = "" + + should "be a singleton", -> + count = 0 + + assert.isTrue Mode.top().name == "insert" + new PostFindMode @element + assert.isTrue Mode.top().name == "post-find" + new PostFindMode @element + assert.isTrue Mode.top().name == "post-find" + Mode.top().exit() + assert.isTrue Mode.top().name == "insert" + + should "suppress unmapped printable keypress events", -> + # Verify key is passed through. + for event in [ "keydown", "keypress", "keyup" ] + key = mockKeyboardEvent "u" + handlerStack.bubbleEvent event, key + assert.isFalse key.suppressed + + new PostFindMode @element + + # Verify key is now suppressed for keypress. + key = mockKeyboardEvent "u" + handlerStack.bubbleEvent "keypress", + extend key, + srcElement: @element + assert.isTrue key.suppressed + + should "be clickable to focus", -> + new PostFindMode @element + + assert.isTrue Mode.top().name != "insert" + handlerStack.bubbleEvent "click", { target: document.activeElement } + assert.isTrue Mode.top().name == "insert" + + should "enter insert mode on immediate escape", -> + + new PostFindMode @element + assert.isTrue Mode.top().name == "post-find" + handlerStack.bubbleEvent "keydown", @escape + assert.isTrue Mode.top().name == "insert" + + should "not enter insert mode on subsequent escape", -> + new PostFindMode @element + assert.isTrue Mode.top().name == "post-find" + handlerStack.bubbleEvent "keydown", mockKeyboardEvent "u" + handlerStack.bubbleEvent "keydown", @escape + assert.isTrue Mode.top().name == "post-find" + +context "Mode badges", + setup -> + backupStackState() + + tearDown -> + restoreStackState() + + should "have no badge without passKeys", -> + handlerStack.bubbleEvent "registerStateChange", + enabled: true + passKeys: "" + + handlerStack.bubbleEvent "updateBadge", badge = { badge: "" } + assert.isTrue badge.badge == "" + + should "have no badge with passKeys", -> + handlerStack.bubbleEvent "registerStateChange", + enabled: true + passKeys: "p" + + handlerStack.bubbleEvent "updateBadge", badge = { badge: "" } + assert.isTrue badge.badge == "" + + should "have no badge when disabled", -> + handlerStack.bubbleEvent "registerStateChange", + enabled: false + passKeys: "" + + new InsertMode() + handlerStack.bubbleEvent "updateBadge", badge = { badge: "" } + assert.isTrue badge.badge == "" + diff --git a/tests/dom_tests/dom_tests.html b/tests/dom_tests/dom_tests.html index feddafac..33759abd 100644 --- a/tests/dom_tests/dom_tests.html +++ b/tests/dom_tests/dom_tests.html @@ -32,11 +32,18 @@ <script type="text/javascript" src="../../lib/utils.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/rect.js"></script> <script type="text/javascript" src="../../lib/handler_stack.js"></script> <script type="text/javascript" src="../../lib/clipboard.js"></script> + <script type="text/javascript" src="../../content_scripts/ui_component.js"></script> <script type="text/javascript" src="../../content_scripts/link_hints.js"></script> <script type="text/javascript" src="../../content_scripts/vomnibar.js"></script> <script type="text/javascript" src="../../content_scripts/scroller.js"></script> + <script type="text/javascript" src="../../content_scripts/mode.js"></script> + <script type="text/javascript" src="../../content_scripts/mode_passkeys.js"></script> + <script type="text/javascript" src="../../content_scripts/mode_insert.js"></script> + <script type="text/javascript" src="../../content_scripts/mode_find.js"></script> + <script type="text/javascript" src="../../content_scripts/mode_visual.js"></script> <script type="text/javascript" src="../../content_scripts/vimium_frontend.js"></script> <script type="text/javascript" src="../shoulda.js/shoulda.js"></script> @@ -52,6 +59,5 @@ <h1>Vimium Tests</h1> <div id="output-div"></div> - </body> </html> diff --git a/tests/dom_tests/dom_utils_test.coffee b/tests/dom_tests/dom_utils_test.coffee index 130a3014..ad8bde3c 100644 --- a/tests/dom_tests/dom_utils_test.coffee +++ b/tests/dom_tests/dom_utils_test.coffee @@ -50,12 +50,6 @@ context "Check visibility", assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'foo') != null assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'bar') != null - should "detect opacity:0 links as hidden", -> - document.getElementById("test-div").innerHTML = """ - <a id='foo' style='opacity:0'>test</a> - """ - assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo' - should "detect links that contain only floated / absolutely-positioned divs as visible", -> document.getElementById("test-div").innerHTML = """ <a id='foo'> diff --git a/tests/dom_tests/vomnibar_test.coffee b/tests/dom_tests/vomnibar_test.coffee index b414fdfb..0e02bb7b 100644 --- a/tests/dom_tests/vomnibar_test.coffee +++ b/tests/dom_tests/vomnibar_test.coffee @@ -1,19 +1,34 @@ +vomnibarFrame = null + context "Keep selection within bounds", setup -> @completions = [] - oldGetCompleter = Vomnibar.getCompleter.bind Vomnibar - stub Vomnibar, 'getCompleter', (name) => + + vomnibarFrame = Vomnibar.vomnibarUI.iframeElement.contentWindow + + # The Vomnibar frame is dynamically injected, so inject our stubs here. + vomnibarFrame.Function::bind = Function::bind + vomnibarFrame.chrome = chrome + + oldGetCompleter = vomnibarFrame.Vomnibar.getCompleter.bind vomnibarFrame.Vomnibar + stub vomnibarFrame.Vomnibar, 'getCompleter', (name) => completer = oldGetCompleter name stub completer, 'filter', (query, callback) => callback(@completions) completer + # Shoulda.js doesn't support async tests, so we have to hack around. + stub Vomnibar.vomnibarUI, "postMessage", (data) -> + vomnibarFrame.UIComponentServer.handleMessage {data} + stub vomnibarFrame.UIComponentServer, "postMessage", (data) -> + UIComponent.handleMessage {data} + tearDown -> Vomnibar.vomnibarUI.hide() should "set selection to position -1 for omni completion by default", -> Vomnibar.activate() - ui = Vomnibar.vomnibarUI + ui = vomnibarFrame.Vomnibar.vomnibarUI @completions = [] ui.update(true) @@ -29,7 +44,7 @@ context "Keep selection within bounds", should "set selection to position 0 for bookmark completion if possible", -> Vomnibar.activateBookmarks() - ui = Vomnibar.vomnibarUI + ui = vomnibarFrame.Vomnibar.vomnibarUI @completions = [] ui.update(true) @@ -45,7 +60,7 @@ context "Keep selection within bounds", should "keep selection within bounds", -> Vomnibar.activate() - ui = Vomnibar.vomnibarUI + ui = vomnibarFrame.Vomnibar.vomnibarUI @completions = [] ui.update(true) diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index e4966016..b7b73cc2 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -233,17 +233,24 @@ context "tab completer", context "search engines", setup -> - searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s" + searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s baz description" Settings.set 'searchEngines', searchEngines @completer = new SearchEngineCompleter() # note, I couldn't just call @completer.refresh() here as I couldn't set root.Settings without errors # workaround is below, would be good for someone that understands the testing system better than me to improve @completer.searchEngines = Settings.getSearchEngines() - should "return search engine suggestion", -> + should "return search engine suggestion without description", -> results = filterCompleter(@completer, ["foo", "hello"]) assert.arrayEqual ["bar?q=hello"], results.map (result) -> result.url assert.arrayEqual ["foo: hello"], results.map (result) -> result.title + assert.arrayEqual ["search"], results.map (result) -> result.type + + should "return search engine suggestion with description", -> + results = filterCompleter(@completer, ["baz", "hello"]) + assert.arrayEqual ["qux?q=hello"], results.map (result) -> result.url + assert.arrayEqual ["hello"], results.map (result) -> result.title + assert.arrayEqual ["baz description"], results.map (result) -> result.type context "suggestions", should "escape html in page titles", -> diff --git a/tests/unit_tests/exclusion_test.coffee b/tests/unit_tests/exclusion_test.coffee index 25bd8125..b3ed7194 100644 --- a/tests/unit_tests/exclusion_test.coffee +++ b/tests/unit_tests/exclusion_test.coffee @@ -25,49 +25,33 @@ extend(global, require "../../background_scripts/main.js") # context "Excluded URLs and pass keys", - # These tests have no setup, they use the default values from settings.coffee. + setup -> + Exclusions.postUpdateHook( + [ + { pattern: "http*://mail.google.com/*", passKeys: "" } + { pattern: "http*://www.facebook.com/*", passKeys: "abab" } + { pattern: "http*://www.facebook.com/*", passKeys: "cdcd" } + { pattern: "http*://www.bbc.com/*", passKeys: "" } + { pattern: "http*://www.bbc.com/*", passKeys: "ab" } + ]) should "be disabled for excluded sites", -> - rule = isEnabledForUrl({ url: 'http://www.google.com/calendar/page' }) - assert.isFalse rule.isEnableForUrl + rule = isEnabledForUrl({ url: 'http://mail.google.com/calendar/page' }) + assert.isFalse rule.isEnabledForUrl assert.isFalse rule.passKeys - should "be enabled, but with pass keys", -> - rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' }) - assert.isTrue rule.isEnabledForUrl + should "be disabled for excluded sites, one exclusion", -> + rule = isEnabledForUrl({ url: 'http://www.bbc.com/calendar/page' }) + assert.isFalse rule.isEnabledForUrl assert.isFalse rule.passKeys - addExclusionRule("http*://www.facebook.com/*","oO") + + should "be enabled, but with pass keys", -> rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' }) assert.isTrue rule.isEnabledForUrl - assert.equal rule.passKeys, 'oO' + assert.equal rule.passKeys, 'abcd' should "be enabled", -> rule = isEnabledForUrl({ url: 'http://www.twitter.com/pages' }) assert.isTrue rule.isEnabledForUrl assert.isFalse rule.passKeys - should "add a new excluded URL", -> - rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) - assert.isTrue rule.isEnabledForUrl - addExclusionRule("http://www.example.com*") - rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) - assert.isFalse rule.isEnabledForUrl - assert.isFalse rule.passKeys - - should "add a new excluded URL with passkeys", -> - rule = isEnabledForUrl({ url: 'http://www.anotherexample.com/page' }) - assert.isTrue rule.isEnabledForUrl - addExclusionRule("http://www.anotherexample.com/*","jk") - rule = isEnabledForUrl({ url: 'http://www.anotherexample.com/page' }) - assert.isTrue rule.isEnabledForUrl - assert.equal rule.passKeys, 'jk' - - should "update an existing excluded URL with passkeys", -> - rule = isEnabledForUrl({ url: 'http://mail.google.com/page' }) - assert.isFalse rule.isEnabledForUrl - assert.isFalse rule.passKeys - addExclusionRule("http*://mail.google.com/*","jknp") - rule = isEnabledForUrl({ url: 'http://mail.google.com/page' }) - assert.isTrue rule.isEnabledForUrl - assert.equal rule.passKeys, 'jknp' - diff --git a/tests/unit_tests/handler_stack_test.coffee b/tests/unit_tests/handler_stack_test.coffee index 0ed8f4c0..0ed85e63 100644 --- a/tests/unit_tests/handler_stack_test.coffee +++ b/tests/unit_tests/handler_stack_test.coffee @@ -23,6 +23,29 @@ context "handlerStack", assert.isTrue @handler2Called assert.isFalse @handler1Called + should "terminate bubbling on stopBubblingAndTrue, and be true", -> + @handlerStack.push { keydown: => @handler1Called = true } + @handlerStack.push { keydown: => @handler2Called = true; @handlerStack.stopBubblingAndTrue } + assert.isTrue @handlerStack.bubbleEvent 'keydown', {} + assert.isTrue @handler2Called + assert.isFalse @handler1Called + + should "terminate bubbling on stopBubblingAndTrue, and be false", -> + @handlerStack.push { keydown: => @handler1Called = true } + @handlerStack.push { keydown: => @handler2Called = true; @handlerStack.stopBubblingAndFalse } + assert.isFalse @handlerStack.bubbleEvent 'keydown', {} + assert.isTrue @handler2Called + assert.isFalse @handler1Called + + should "restart bubbling on restartBubbling", -> + @handler1Called = 0 + @handler2Called = 0 + id = @handlerStack.push { keydown: => @handler1Called++; @handlerStack.remove(id); @handlerStack.restartBubbling } + @handlerStack.push { keydown: => @handler2Called++; true } + assert.isTrue @handlerStack.bubbleEvent 'keydown', {} + assert.isTrue @handler1Called == 1 + assert.isTrue @handler2Called == 2 + should "remove handlers correctly", -> @handlerStack.push { keydown: => @handler1Called = true } handlerId = @handlerStack.push { keydown: => @handler2Called = true } diff --git a/tests/unit_tests/rect_test.coffee b/tests/unit_tests/rect_test.coffee new file mode 100644 index 00000000..cfb26b05 --- /dev/null +++ b/tests/unit_tests/rect_test.coffee @@ -0,0 +1,232 @@ +require "./test_helper.js" +extend(global, require "../../lib/rect.js") + +context "Rect", + should "set rect properties correctly", -> + [x1, y1, x2, y2] = [1, 2, 3, 4] + rect = Rect.create x1, y1, x2, y2 + assert.equal rect.left, x1 + assert.equal rect.top, y1 + assert.equal rect.right, x2 + assert.equal rect.bottom, y2 + assert.equal rect.width, x2 - x1 + assert.equal rect.height, y2 - y1 + + should "translate rect horizontally", -> + [x1, y1, x2, y2] = [1, 2, 3, 4] + x = 5 + rect1 = Rect.create x1, y1, x2, y2 + rect2 = Rect.translate rect1, x + + assert.equal rect1.left + x, rect2.left + assert.equal rect1.right + x, rect2.right + + assert.equal rect1.width, rect2.width + assert.equal rect1.height, rect2.height + assert.equal rect1.top, rect2.top + assert.equal rect1.bottom, rect2.bottom + + should "translate rect vertically", -> + [x1, y1, x2, y2] = [1, 2, 3, 4] + y = 5 + rect1 = Rect.create x1, y1, x2, y2 + rect2 = Rect.translate rect1, undefined, y + + assert.equal rect1.top + y, rect2.top + assert.equal rect1.bottom + y, rect2.bottom + + assert.equal rect1.width, rect2.width + assert.equal rect1.height, rect2.height + assert.equal rect1.left, rect2.left + assert.equal rect1.right, rect2.right + +context "Rect subtraction", + context "unchanged by rects outside", + should "left, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -2, -2, -1, -1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -2, 0, -1, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -2, 2, -1, 3 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 2, -2, 3, -1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 2, 0, 3, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 2, 2, 3, 3 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, -2, 1, -1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, 2, 1, 3 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + context "unchanged by rects touching", + should "left, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -1, -1, 0, 0 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -1, 0, 0, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -1, 1, 0, 2 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 1, -1, 2, 0 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 1, 0, 2, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 1, 1, 2, 2 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, -1, 1, 0 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, 1, 1, 2 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "have nothing when subtracting itself", -> + rect = Rect.create 0, 0, 1, 1 + rects = Rect.subtract rect, rect + assert.equal rects.length, 0 + + should "not overlap subtracted rect", -> + rect = Rect.create 0, 0, 3, 3 + for x in [-2..2] + for y in [-2..2] + for width in [1..3] + for height in [1..3] + subtractRect = Rect.create x, y, (x + width), (y + height) + resultRects = Rect.subtract rect, subtractRect + for resultRect in resultRects + assert.isFalse Rect.contains subtractRect, resultRect + + should "be contained in original rect", -> + rect = Rect.create 0, 0, 3, 3 + for x in [-2..2] + for y in [-2..2] + for width in [1..3] + for height in [1..3] + subtractRect = Rect.create x, y, (x + width), (y + height) + resultRects = Rect.subtract rect, subtractRect + for resultRect in resultRects + assert.isTrue Rect.contains rect, resultRect + + should "contain the subtracted rect in the original minus the results", -> + rect = Rect.create 0, 0, 3, 3 + for x in [-2..2] + for y in [-2..2] + for width in [1..3] + for height in [1..3] + subtractRect = Rect.create x, y, (x + width), (y + height) + resultRects = Rect.subtract rect, subtractRect + resultComplement = [Rect.copy rect] + for resultRect in resultRects + resultComplement = Array::concat.apply [], + (resultComplement.map (rect) -> Rect.subtract rect, resultRect) + assert.isTrue (resultComplement.length == 0 or resultComplement.length == 1) + if resultComplement.length == 1 + complementRect = resultComplement[0] + assert.isTrue Rect.contains subtractRect, complementRect diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee index 4625457b..afe862a4 100644 --- a/tests/unit_tests/settings_test.coffee +++ b/tests/unit_tests/settings_test.coffee @@ -70,14 +70,15 @@ context "settings", chrome.storage.sync.set { scrollStepSize: JSON.stringify(message) } assert.equal message, Sync.message - should "set search engines, retrieve them correctly and check that it has been parsed correctly", -> - searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s" - parsedSearchEngines = {"foo": "bar?q=%s", "baz": "qux?q=%s"} + should "set search engines, retrieve them correctly and check that they have been parsed correctly", -> + searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s baz description" Settings.set 'searchEngines', searchEngines - assert.equal(searchEngines, Settings.get('searchEngines')) result = Settings.getSearchEngines() - assert.isTrue(parsedSearchEngines["foo"] == result["foo"] && - parsedSearchEngines["baz"] == result["baz"] && Object.keys(result).length == 2) + assert.equal Object.keys(result).length, 2 + assert.equal "bar?q=%s", result["foo"].url + assert.isFalse result["foo"].description + assert.equal "qux?q=%s", result["baz"].url + assert.equal "baz description", result["baz"].description should "sync a key which is not a known setting (without crashing)", -> chrome.storage.sync.set { notASetting: JSON.stringify("notAUsefullValue") } diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index 80750337..7f666068 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -41,7 +41,13 @@ exports.chrome = addListener: () -> true getAll: () -> true + browserAction: + setBadgeBackgroundColor: -> storage: + # chrome.storage.local + local: + set: -> + # chrome.storage.onChanged onChanged: addListener: (func) -> @func = func diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee index 556f5b7a..88e9a15b 100644 --- a/tests/unit_tests/utils_test.coffee +++ b/tests/unit_tests/utils_test.coffee @@ -45,7 +45,8 @@ context "convertToUrl", should "convert non-URL terms into search queries", -> assert.equal "http://www.google.com/search?q=google", Utils.convertToUrl("google") - assert.equal "http://www.google.com/search?q=go%20ogle.com", Utils.convertToUrl("go ogle.com") + assert.equal "http://www.google.com/search?q=go+ogle.com", Utils.convertToUrl("go ogle.com") + assert.equal "http://www.google.com/search?q=%40twitter", Utils.convertToUrl("@twitter") context "hasChromePrefix", should "detect chrome prefixes of URLs", -> |
