diff options
| -rw-r--r-- | CONTRIBUTING.md | 48 | ||||
| -rw-r--r-- | README.md | 12 | ||||
| -rw-r--r-- | background_scripts/completion.coffee | 60 | ||||
| -rw-r--r-- | background_scripts/exclusions.coffee | 7 | ||||
| -rw-r--r-- | background_scripts/main.coffee | 57 | ||||
| -rw-r--r-- | background_scripts/settings.coffee | 1 | ||||
| -rw-r--r-- | content_scripts/scroller.coffee | 254 | ||||
| -rw-r--r-- | content_scripts/vimium.css | 24 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 41 | ||||
| -rw-r--r-- | lib/dom_utils.coffee | 4 | ||||
| -rw-r--r-- | lib/utils.coffee | 19 | ||||
| -rw-r--r-- | pages/options.coffee | 39 | ||||
| -rw-r--r-- | pages/options.html | 178 | ||||
| -rw-r--r-- | pages/popup.coffee | 16 | ||||
| -rw-r--r-- | pages/popup.html | 2 | ||||
| -rw-r--r-- | tests/unit_tests/completion_test.coffee | 66 | ||||
| -rw-r--r-- | tests/unit_tests/test_chrome_stubs.coffee | 4 | ||||
| -rw-r--r-- | tests/unit_tests/utils_test.coffee | 7 | 
18 files changed, 580 insertions, 259 deletions
| 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 diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index 23696185..dc5519d5 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -26,7 +26,6 @@ class Suggestion    generateHtml: ->      return @html if @html -    favIconUrl = @tabFavIconUrl or "#{@getUrlRoot(@url)}/favicon.ico"      relevancyHtml = if @showRelevancy then "<span class='relevancy'>#{@computeRelevancy()}</span>" else ""      # NOTE(philc): We're using these vimium-specific class names so we don't collide with the page's CSS.      @html = @@ -35,8 +34,7 @@ class Suggestion           <span class="vimiumReset vomnibarSource">#{@type}</span>           <span class="vimiumReset vomnibarTitle">#{@highlightTerms(Utils.escapeHtml(@title))}</span>         </div> -       <div class="vimiumReset vomnibarBottomHalf vomnibarIcon" -            style="background-image: url(#{favIconUrl});"> +       <div class="vimiumReset vomnibarBottomHalf">          <span class="vimiumReset vomnibarUrl">#{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))}</span>          #{relevancyHtml}        </div> @@ -238,7 +236,7 @@ class DomainCompleter        onComplete()    onPageVisited: (newPage) -> -    domain = @parseDomain(newPage.url) +    domain = @parseDomainAndScheme newPage.url      if domain        slot = @domains[domain] ||= { entry: newPage, referenceCount: 0 }        # We want each entry in our domains hash to point to the most recent History entry for that domain. @@ -250,15 +248,58 @@ class DomainCompleter        @domains = {}      else        toRemove.urls.forEach (url) => -        domain = @parseDomain(url) +        domain = @parseDomainAndScheme url          if domain and @domains[domain] and ( @domains[domain].referenceCount -= 1 ) == 0            delete @domains[domain] -  parseDomain: (url) -> url.split("/")[2] || "" +  # Return something like "http://www.example.com" or false. +  parseDomainAndScheme: (url) -> +      Utils.hasFullUrlPrefix(url) and not Utils.hasChromePrefix(url) and url.split("/",3).join "/"    # Suggestions from the Domain completer have the maximum relevancy. They should be shown first in the list.    computeRelevancy: -> 1 +# TabRecency associates a logical timestamp with each tab id.  These are used to provide an initial +# recency-based ordering in the tabs vomnibar (which allows jumping quickly between recently-visited tabs). +class TabRecency +  timestamp: 1 +  current: -1 +  cache: {} +  lastVisited: null +  lastVisitedTime: null +  timeDelta: 500 # Milliseconds. + +  constructor: -> +    chrome.tabs.onActivated.addListener (activeInfo) => @register activeInfo.tabId +    chrome.tabs.onRemoved.addListener (tabId) => @deregister tabId + +    chrome.tabs.onReplaced.addListener (addedTabId, removedTabId) => +      @deregister removedTabId +      @register addedTabId + +  register: (tabId) -> +    currentTime = new Date() +    # Register tabId if it has been visited for at least @timeDelta ms.  Tabs which are visited only for a +    # very-short time (e.g. those passed through with `5J`) aren't registered as visited at all. +    if @lastVisitedTime? and @timeDelta <= currentTime - @lastVisitedTime +      @cache[@lastVisited] = ++@timestamp + +    @current = @lastVisited = tabId +    @lastVisitedTime = currentTime + +  deregister: (tabId) -> +    if tabId == @lastVisited +      # Ensure we don't register this tab, since it's going away. +      @lastVisited = @lastVisitedTime = null +    delete @cache[tabId] + +  # Recently-visited tabs get a higher score (except the current tab, which gets a low score). +  recencyScore: (tabId) -> +    @cache[tabId] ||= 1 +    if tabId == @current then 0.0 else @cache[tabId] / @timestamp + +tabRecency = new TabRecency() +  # Searches through all open tabs, matching on title and URL.  class TabCompleter    filter: (queryTerms, onComplete) -> @@ -269,12 +310,14 @@ class TabCompleter        suggestions = results.map (tab) =>          suggestion = new Suggestion(queryTerms, "tab", tab.url, tab.title, @computeRelevancy)          suggestion.tabId = tab.id -        suggestion.tabFavIconUrl = tab.favIconUrl          suggestion        onComplete(suggestions)    computeRelevancy: (suggestion) -> -    RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title) +    if suggestion.queryTerms.length +      RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title) +    else +      tabRecency.recencyScore(suggestion.tabId)  # A completer which will return your search engines  class SearchEngineCompleter @@ -547,3 +590,4 @@ root.SearchEngineCompleter = SearchEngineCompleter  root.HistoryCache = HistoryCache  root.RankingUtils = RankingUtils  root.RegexpCache = RegexpCache +root.TabRecency = TabRecency diff --git a/background_scripts/exclusions.coffee b/background_scripts/exclusions.coffee index 3a8ef1e7..2b34238b 100644 --- a/background_scripts/exclusions.coffee +++ b/background_scripts/exclusions.coffee @@ -6,7 +6,12 @@ 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. diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index b40907fb..3ec618c9 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -8,7 +8,7 @@ keyQueue = "" # Queue of keys typed  validFirstKeys = {}  singleKeyCommands = []  focusedFrame = null -framesForTab = {} +frameIdsForTab = {}  # Keys are either literal characters, or "named" - for example <a-b> (alt+b), <left> (left arrow) or <f12>  # This regular expression captures two groups: the first is a named key, the second is the remainder of @@ -282,16 +282,14 @@ BackgroundCommands =          { name: "toggleHelpDialog", dialogHtml: helpDialogHtml(), frameId:frameId }))    moveTabLeft: (count) -> moveTab(null, -count)    moveTabRight: (count) -> moveTab(null, count) -  nextFrame: (count) -> +  nextFrame: (count,frameId) ->      chrome.tabs.getSelected(null, (tab) -> -      frames = framesForTab[tab.id].frames -      currIndex = getCurrFrameIndex(frames) - -      # TODO: Skip the "top" frame (which doesn't actually have a <frame> tag), -      # since it exists only to contain the other frames. -      newIndex = (currIndex + count) % frames.length - -      chrome.tabs.sendMessage(tab.id, { name: "focusFrame", frameId: frames[newIndex].id, highlight: true })) +      frames = frameIdsForTab[tab.id] +      # We can't always track which frame chrome has focussed, but here we learn that it's frameId; so add an +      # additional offset such that we do indeed start from frameId. +      count = (count + Math.max 0, frameIdsForTab[tab.id].indexOf frameId) % frames.length +      frames = frameIdsForTab[tab.id] = [frames[count..]..., frames[0...count]...] +      chrome.tabs.sendMessage(tab.id, { name: "focusFrame", frameId: frames[0], highlight: true }))    closeTabsOnLeft: -> removeTabsRelative "before"    closeTabsOnRight: -> removeTabsRelative "after" @@ -347,7 +345,7 @@ updateOpenTabs = (tab) ->      scrollY: null      deletor: null    # Frames are recreated on refresh -  delete framesForTab[tab.id] +  delete frameIdsForTab[tab.id]  setBrowserActionIcon = (tabId,path) ->    chrome.browserAction.setIcon({ tabId: tabId, path: path }) @@ -394,7 +392,7 @@ chrome.tabs.onUpdated.addListener (tabId, changeInfo, tab) ->      code: Settings.get("userDefinedLinkHintCss")      runAt: "document_start"    chrome.tabs.insertCSS tabId, cssConf, -> chrome.runtime.lastError -  updateOpenTabs(tab) +  updateOpenTabs(tab) if changeInfo.url?    updateActiveState(tabId)  chrome.tabs.onAttached.addListener (tabId, attachedInfo) -> @@ -429,7 +427,7 @@ chrome.tabs.onRemoved.addListener (tabId) ->    # scroll position)    tabInfoMap.deletor = -> delete tabInfoMap[tabId]    setTimeout tabInfoMap.deletor, 1000 -  delete framesForTab[tabId] +  delete frameIdsForTab[tabId]  chrome.tabs.onActiveChanged.addListener (tabId, selectInfo) -> updateActiveState(tabId) @@ -554,9 +552,9 @@ checkKeyQueue = (keysToCheck, tabId, frameId) ->          refreshedCompletionKeys = true        else          if registryEntry.passCountToFunction -          BackgroundCommands[registryEntry.command](count) +          BackgroundCommands[registryEntry.command](count, frameId)          else if registryEntry.noRepeat -          BackgroundCommands[registryEntry.command]() +          BackgroundCommands[registryEntry.command](frameId)          else            repeatFunction(BackgroundCommands[registryEntry.command], count, 0, frameId) @@ -603,21 +601,21 @@ openOptionsPageInNewTab = ->      chrome.tabs.create({ url: chrome.runtime.getURL("pages/options.html"), index: tab.index + 1 }))  registerFrame = (request, sender) -> -  unless framesForTab[sender.tab.id] -    framesForTab[sender.tab.id] = { frames: [] } - -  if (request.is_top) -    focusedFrame = request.frameId -    framesForTab[sender.tab.id].total = request.total +  (frameIdsForTab[sender.tab.id] ?= []).push request.frameId -  framesForTab[sender.tab.id].frames.push({ id: request.frameId }) - -handleFrameFocused = (request, sender) -> focusedFrame = request.frameId +unregisterFrame = (request, sender) -> +  tabId = sender.tab.id +  if frameIdsForTab[tabId]? +    if request.tab_is_closing +      updateOpenTabs sender.tab +    else +      frameIdsForTab[tabId] = frameIdsForTab[tabId].filter (id) -> id != request.frameId -getCurrFrameIndex = (frames) -> -  for i in [0...frames.length] -    return i if frames[i].id == focusedFrame -  frames.length + 1 +handleFrameFocused = (request, sender) -> +  tabId = sender.tab.id +  if frameIdsForTab[tabId]? +    frameIdsForTab[tabId] = +      [request.frameId, (frameIdsForTab[tabId].filter (id) -> id != request.frameId)...]  # Port handler mapping  portHandlers = @@ -633,6 +631,7 @@ sendRequestHandlers =    openUrlInCurrentTab: openUrlInCurrentTab,    openOptionsPageInNewTab: openOptionsPageInNewTab,    registerFrame: registerFrame, +  unregisterFrame: unregisterFrame,    frameFocused: handleFrameFocused,    upgradeNotificationClosed: upgradeNotificationClosed,    updateScrollPosition: handleUpdateScrollPosition, @@ -640,7 +639,7 @@ sendRequestHandlers =    isEnabledForUrl: isEnabledForUrl,    saveHelpDialogSettings: saveHelpDialogSettings,    selectSpecificTab: selectSpecificTab, -  refreshCompleter: refreshCompleter +  refreshCompleter: refreshCompleter,    createMark: Marks.create.bind(Marks),    gotoMark: Marks.goto.bind(Marks) diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index d6e8fcde..4a63a5fb 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -61,6 +61,7 @@ root.Settings = Settings =    # or strings    defaults:      scrollStepSize: 60 +    smoothScroll: true      keyMappings: "# Insert your prefered key mappings here."      linkHintCharacters: "sadfjklewcmpgh"      linkHintNumbers: "0123456789" diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index f3c632b3..5eb1c5e2 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -1,93 +1,217 @@ -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 -  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 the requested amount, and false otherwise. +performScroll = (element, direction, amount) ->    axisName = scrollProperties[direction].axisName -  element = activatedElement -  loop -    oldScrollValue = element[axisName] -    changeFn(element, axisName) -    break unless (element[axisName] == oldScrollValue && element != document.body) -    lastElement = element -    # 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] == amount + 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 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 = Math.sign 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 + +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 +      keydown: (event) => +        @keyIsDown = true +        @lastEvent = event +      keyup: => +        @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 = Math.sign 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 DOMActivate: -> 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 +    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) -> +    return unless document.body or activatedElement +    activatedElement ||= document.body + +    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/vimium.css b/content_scripts/vimium.css index 24f229f3..a8ae5583 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 use 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 */ @@ -122,7 +126,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; } @@ -233,8 +238,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; @@ -291,7 +296,7 @@ body.vimiumFindMode ::selection {    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; +  z-index: 2147483645;  }  #vomnibar input { @@ -352,13 +357,6 @@ body.vimiumFindMode ::selection {    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; @@ -408,5 +406,5 @@ div#vimiumFlash {    padding: 1px;    background-color: transparent;    position: absolute; -  z-index: 99999; +  z-index: 2147483648;  } diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 118f985e..469afe71 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -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: {} @@ -101,7 +101,7 @@ initializePreDomReady = ->    settings.addEventListener("load", LinkHints.init.bind(LinkHints))    settings.load() -  Scroller.init() +  Scroller.init settings    checkIfEnabledForUrl() @@ -179,19 +179,24 @@ window.addEventListener "focus", ->  # Initialization tasks that must wait for the document to be ready.  #  initializeOnDomReady = -> -  registerFrame(window.top == window.self) -    enterInsertModeIfElementIsFocused() if isEnabledForUrl    # Tell the background page we're in the dom ready state.    chrome.runtime.connect({ name: "domReady" }) -registerFrame = (is_top) -> -  chrome.runtime.sendMessage( -    handler: "registerFrame" +registerFrame = -> +  # Don't register frameset containers; focusing them is no use. +  if document.body.tagName != "FRAMESET" +    chrome.runtime.sendMessage +      handler: "registerFrame" +      frameId: frameId + +# Unregister the frame if we're going to exit. +unregisterFrame = -> +  chrome.runtime.sendMessage +    handler: "unregisterFrame"      frameId: frameId -    is_top: is_top -    total: frames.length + 1) +    tab_is_closing: window.top == window.self  #  # Enters insert mode if the currently focused element in the DOM is focusable. @@ -367,7 +372,7 @@ onKeypress = (event) ->        else if (!isInsertMode() && !findMode)          if (isPassKey keyChar)            return undefined -        if (currentCompletionKeys.indexOf(keyChar) != -1) +        if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar))            DomUtils.suppressEvent(event)          keyPort.postMessage({ keyChar:keyChar, frameId:frameId }) @@ -440,7 +445,7 @@ onKeydown = (event) ->    else if (!isInsertMode() && !findMode)      if (keyChar) -      if (currentCompletionKeys.indexOf(keyChar) != -1) +      if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar))          DomUtils.suppressEvent event          handledKeydownEvents.push event @@ -502,7 +507,7 @@ refreshCompletionKeys = (response) ->      chrome.runtime.sendMessage({ handler: "getCompletionKeys" }, refreshCompletionKeys)  isValidFirstKey = (keyChar) -> -  validFirstKeys[keyChar] || /[1-9]/.test(keyChar) +  validFirstKeys[keyChar] || /^[1-9]/.test(keyChar)  onFocusCapturePhase = (event) ->    if (isFocusable(event.target) && !findMode) @@ -529,6 +534,7 @@ isEmbed = (element) -> ["embed", "object"].indexOf(element.nodeName.toLowerCase(  # any element which makes it a rich text editor, like the notes on jjot.com.  #  isEditable = (target) -> +  # Note: document.activeElement.isContentEditable is also rechecked in isInsertMode() dynamically.    return true if target.isContentEditable    nodeName = target.nodeName.toLowerCase()    # use a blacklist instead of a whitelist because new form controls are still being implemented for html5 @@ -552,6 +558,7 @@ window.enterInsertMode = (target) ->  # 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  # leave insert mode when the user presses <ESC>. +# Note. This returns the truthiness of target, which is required by isInsertMode.  #  enterInsertModeWithoutShowingIndicator = (target) -> insertModeLock = target @@ -560,7 +567,13 @@ exitInsertMode = (target) ->      insertModeLock = null      HUD.hide() -isInsertMode = -> insertModeLock != null +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  # should be called whenever rawQuery is modified.  updateFindModeQuery = -> @@ -1055,6 +1068,8 @@ Tween =        state.onUpdate(value)  initializePreDomReady() +window.addEventListener("DOMContentLoaded", registerFrame) +window.addEventListener("unload", unregisterFrame)  window.addEventListener("DOMContentLoaded", initializeOnDomReady)  window.onbeforeunload = -> diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index 73d29eb6..dfaa5d5f 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -32,8 +32,8 @@ DomUtils =    #    makeXPath: (elementArray) ->      xpath = [] -    for i of elementArray -      xpath.push("//" + elementArray[i], "//xhtml:" + elementArray[i]) +    for element in elementArray +      xpath.push("//" + element, "//xhtml:" + element)      xpath.join(" | ")    evaluateXPath: (xpath, resultType) -> diff --git a/lib/utils.coffee b/lib/utils.coffee index bbcee1a0..b7f8731a 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -26,28 +26,29 @@ Utils =      -> id += 1    hasChromePrefix: do -> -    chromePrefixes = [ "about:", "view-source:", "chrome-extension:", "data:" ] +    chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:" ]      (url) ->        if 0 < url.indexOf ":"          for prefix in chromePrefixes            return true if url.startsWith prefix        false +  hasFullUrlPrefix: do -> +    urlPrefix = new RegExp "^[a-z]{3,}://." +    (url) -> urlPrefix.test url +    # Completes a partial URL (without scheme)    createFullUrl: (partialUrl) -> -    unless /^[a-z]{3,}:\/\//.test partialUrl -      "http://" + partialUrl -    else -      partialUrl +    if @hasFullUrlPrefix(partialUrl) then partialUrl else ("http://" + partialUrl)    # Tries to detect if :str is a valid URL.    isUrl: (str) -> -    # Starts with a scheme: URL -    return true if /^[a-z]{3,}:\/\//.test str -      # Must not contain spaces      return false if ' ' in str +    # Starts with a scheme: URL +    return true if @hasFullUrlPrefix str +      # More or less RFC compliant URL host part parsing. This should be sufficient for our needs      urlRegex = new RegExp(        '^(?:([^:]+)(?::([^:]+))?@)?' + # user:password (optional) => \1, \2 @@ -98,7 +99,7 @@ Utils =    convertToUrl: (string) ->      string = string.trim() -    # Special-case about:[url] and view-source:[url] +    # Special-case about:[url], view-source:[url] and the like      if Utils.hasChromePrefix string        string      else if Utils.isUrl string diff --git a/pages/options.coffee b/pages/options.coffee index f5968eb9..cd19fa37 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -42,13 +42,7 @@ class Option    @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() +    $("saveOptions").innerHTML = "No Changes"    # Abstract method; only implemented in sub-classes.    # Populate the option's DOM element (@element) with the setting's current value. @@ -66,7 +60,6 @@ class TextOption extends Option    constructor: (field,enableSaveButton) ->      super(field,enableSaveButton)      @element.addEventListener "input", enableSaveButton -    @activateCtrlEnterListener @element    populateElement: (value) -> @element.value = value    readValueFromElement: -> @element.value.trim() @@ -74,7 +67,6 @@ class NonEmptyTextOption extends Option    constructor: (field,enableSaveButton) ->      super(field,enableSaveButton)      @element.addEventListener "input", enableSaveButton -    @activateCtrlEnterListener @element    populateElement: (value) -> @element.value = value    # If the new value is not empty, then return it. Otherwise, restore the default value. @@ -89,7 +81,6 @@ class ExclusionRulesOption extends Option      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. @@ -97,11 +88,8 @@ class ExclusionRulesOption extends Option        exclusionScrollBox.scrollTop = exclusionScrollBox.scrollHeight    populateElement: (rules) -> -    while @element.firstChild -      @element.removeChild @element.firstChild      for rule in rules        @appendRule rule -    @maintainExclusionMargin()    # Append a row for a new rule.    appendRule: (rule) -> @@ -111,7 +99,6 @@ 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 @@ -120,13 +107,12 @@ class ExclusionRulesOption extends Option        row = event.target.parentNode.parentNode        row.parentNode.removeChild row        enableSaveButton() -      @maintainExclusionMargin()      @element.appendChild row    readValueFromElement: ->      rules = -      for element in @element.children +      for element in @element.getElementsByClassName "exclusionRuleTemplateInstance"          pattern = element.children[0].firstChild.value.trim()          passKeys = element.children[1].firstChild.value.trim()          { pattern: pattern, passKeys: passKeys } @@ -138,20 +124,11 @@ 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 -  #  # Operations for page elements.  enableSaveButton = ->    $("saveOptions").removeAttribute "disabled" +  $("saveOptions").innerHTML = "Save Changes"  # Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints".  maintainLinkHintsView = -> @@ -175,9 +152,13 @@ toggleAdvancedOptions =          $("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. +  document.activeElement.blur()  #  # Initialization. @@ -196,6 +177,7 @@ document.addEventListener "DOMContentLoaded", ->      previousPatterns: NonEmptyTextOption      regexFindMode: CheckBoxOption      scrollStepSize: NumberOption +    smoothScroll: CheckBoxOption      searchEngines: TextOption      searchUrl: NonEmptyTextOption      userDefinedLinkHintCss: TextOption @@ -213,3 +195,8 @@ document.addEventListener "DOMContentLoaded", ->    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 +      Option.saveOptions() + diff --git a/pages/options.html b/pages/options.html index 4f037ba5..b52974d6 100644 --- a/pages/options.html +++ b/pages/options.html @@ -6,12 +6,14 @@        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; } +      div#wrapper, #footerWrapper { +        width: 540px; +        margin-left: 35px; +      }        header {          font-size: 18px;          font-weight: normal; @@ -112,21 +114,11 @@          width: 40px;          margin-right: 3px;        } -      textarea#userDefinedLinkHintCss { +      textarea#userDefinedLinkHintCss, textarea#keyMappings, textarea#searchEngines {          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%;        } @@ -141,20 +133,13 @@          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 { +      .caption {          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; @@ -180,34 +165,84 @@        }        /* 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; +        max-height: 135px; +        min-height: 75px;          border-radius: 2px;          color: #444; +        width: 100% +      } +      #exclusionScrollBox::-webkit-scrollbar { +        width: 12px;        } -      input.pattern, input.passKeys { +      #exclusionScrollBox::-webkit-scrollbar-track { +        -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); +        border-radius: 2px; +      } + +      #exclusionScrollBox::-webkit-scrollbar-thumb { +        border-radius: 2px; +        -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.5); +      } +      #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;        } -      .pattern { -        width: 250px; -      } -      .passKeys { -        width: 120px; +      .exclusionHeaderText { +        padding-left: 3px; +        color: #979ca0;        }        #exclusionAddButton {          float: right; -        margin-top: 5px;          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;        }      </style> @@ -224,24 +259,28 @@            <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> +                  <table id="exclusionRules"> +                     <tr> +                        <td><span class="exclusionHeaderText">Patterns</span></td> +                        <td><span class="exclusionHeaderText">Keys</span></td> +                     </tr> +                  </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> +                     <tr class="exclusionRuleTemplateInstance"> +                        <td><input/ type="text" class="pattern" placeholder="URL pattern"></td> +                        <td class="exclusionRulePassKeys"><input/ type="text" class="passKeys" placeholder="Exclude keys"></td> +                        <td class="exclusionRemoveButton"> +                          <input/ type="button" tabindex = "-1" class="exclusionRemoveButtonButton" value="✖"></td> +                     </tr>                    </template>                 </div>                 <button id="exclusionAddButton">Add Rule</button> @@ -283,13 +322,17 @@ unmapAll            </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 +349,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 +361,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 +407,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 +476,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 index 2ab97bef..99a4eb87 100644 --- a/pages/popup.coffee +++ b/pages/popup.coffee @@ -3,6 +3,17 @@ 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) -> @@ -13,11 +24,8 @@ reset = (initialize=false) ->        originalPattern = originalRule.pattern        originalPassKeys = originalRule.passKeys      else -      # The common use case is to disable Vimium at the domain level. -      # This regexp will match "http://www.example.com/" from "http://www.example.com/path/to/page.html". -      domain = (tab.url.match(/[^\/]*\/\/[^\/]*\//) or tab.url) + "*"        originalRule = null -      originalPattern = domain +      originalPattern = generateDefaultPattern tab.url        originalPassKeys = ""      patternElement = document.getElementById("popupPattern")      passKeysElement = document.getElementById("popupPassKeys") diff --git a/pages/popup.html b/pages/popup.html index 691414f2..775d6c07 100644 --- a/pages/popup.html +++ b/pages/popup.html @@ -76,7 +76,7 @@        <div id="popupMenu">          <ul>            <li> -             <span id="helpText">Type <strong>Ctrl-ENTER</strong> to save and close.</span> +             <span id="helpText">Type <strong>Ctrl-Enter</strong> to save and close.</span>               <a id="optionsLink" target="_blank">Options</a>            </li>          </ul> diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index 811436a9..e4966016 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -1,8 +1,8 @@  require "./test_helper.js"  extend(global, require "../../lib/utils.js")  extend(global, require "../../background_scripts/completion.js") +extend global, require "./test_chrome_stubs.js" -global.chrome = {}  global.document =    createElement: -> {} @@ -163,13 +163,13 @@ context "domain completer",    should "return only a single matching domain", ->      results = filterCompleter(@completer, ["story"]) -    assert.arrayEqual ["history1.com"], results.map (result) -> result.url +    assert.arrayEqual ["http://history1.com"], results.map (result) -> result.url    should "pick domains which are more recent", ->      # These domains are the same except for their last visited time. -    assert.equal "history1.com", filterCompleter(@completer, ["story"])[0].url +    assert.equal "http://history1.com", filterCompleter(@completer, ["story"])[0].url      @history2.lastVisitTime = hours(3) -    assert.equal "history2.com", filterCompleter(@completer, ["story"])[0].url +    assert.equal "http://history2.com", filterCompleter(@completer, ["story"])[0].url    should "returns no results when there's more than one query term, because clearly it's not a domain", ->      assert.arrayEqual [], filterCompleter(@completer, ["his", "tory"]) @@ -194,15 +194,15 @@ context "domain completer (removing entries)",    should "remove 1 entry for domain with reference count of 1", ->      @onVisitRemovedListener { allHistory: false, urls: [@history1.url] } -    assert.equal "history2.com", filterCompleter(@completer, ["story"])[0].url +    assert.equal "http://history2.com", filterCompleter(@completer, ["story"])[0].url      assert.equal 0, filterCompleter(@completer, ["story1"]).length    should "remove 2 entries for domain with reference count of 2", ->      @onVisitRemovedListener { allHistory: false, urls: [@history2.url] } -    assert.equal "history2.com", filterCompleter(@completer, ["story2"])[0].url +    assert.equal "http://history2.com", filterCompleter(@completer, ["story2"])[0].url      @onVisitRemovedListener { allHistory: false, urls: [@history3.url] }      assert.equal 0, filterCompleter(@completer, ["story2"]).length -    assert.equal "history1.com", filterCompleter(@completer, ["story"])[0].url +    assert.equal "http://history1.com", filterCompleter(@completer, ["story"])[0].url    should "remove 3 (all) matching domain entries", ->      @onVisitRemovedListener { allHistory: false, urls: [@history2.url] } @@ -399,6 +399,58 @@ context "RegexpCache",    should "search for a string with a prefix/suffix (negative case)", ->      assert.isTrue "hound dog".search(RegexpCache.get("do", "\\b", "\\b")) == -1 +fakeTimeDeltaElapsing = -> + +context "TabRecency", +  setup -> +    @tabRecency = new TabRecency() + +    fakeTimeDeltaElapsing = => +      if @tabRecency.lastVisitedTime? +        @tabRecency.lastVisitedTime = new Date(@tabRecency.lastVisitedTime - @tabRecency.timeDelta) + +    @tabRecency.register 3 +    fakeTimeDeltaElapsing() +    @tabRecency.register 2 +    fakeTimeDeltaElapsing() +    @tabRecency.register 9 +    fakeTimeDeltaElapsing() +    @tabRecency.register 1 +    @tabRecency.deregister 9 +    fakeTimeDeltaElapsing() +    @tabRecency.register 4 +    fakeTimeDeltaElapsing() + +  should "have entries for recently active tabs", -> +    assert.isTrue @tabRecency.cache[1] +    assert.isTrue @tabRecency.cache[2] +    assert.isTrue @tabRecency.cache[3] + +  should "not have entries for removed tabs", -> +    assert.isFalse @tabRecency.cache[9] + +  should "give a high score to the most recent tab", -> +    assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 1 +    assert.isTrue @tabRecency.recencyScore(3) < @tabRecency.recencyScore 1 +    assert.isTrue @tabRecency.recencyScore(2) < @tabRecency.recencyScore 1 + +  should "give a low score to the current tab", -> +    assert.isTrue @tabRecency.recencyScore(1) > @tabRecency.recencyScore 4 +    assert.isTrue @tabRecency.recencyScore(2) > @tabRecency.recencyScore 4 +    assert.isTrue @tabRecency.recencyScore(3) > @tabRecency.recencyScore 4 + +  should "rank tabs by recency", -> +    assert.isTrue @tabRecency.recencyScore(3) < @tabRecency.recencyScore 2 +    assert.isTrue @tabRecency.recencyScore(2) < @tabRecency.recencyScore 1 +    @tabRecency.register 3 +    fakeTimeDeltaElapsing() +    @tabRecency.register 4 # Making 3 the most recent tab which isn't the current tab. +    assert.isTrue @tabRecency.recencyScore(1) < @tabRecency.recencyScore 3 +    assert.isTrue @tabRecency.recencyScore(2) < @tabRecency.recencyScore 3 +    assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 3 +    assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 1 +    assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 2 +  # A convenience wrapper around completer.filter() so it can be called synchronously in tests.  filterCompleter = (completer, queryTerms) ->    results = [] diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index 2abd26c9..80750337 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -30,6 +30,10 @@ exports.chrome =        addListener: () -> true      onActiveChanged:        addListener: () -> true +    onActivated: +      addListener: () -> true +    onReplaced: +      addListener: () -> true      query: () -> true    windows: diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee index b2d656ab..556f5b7a 100644 --- a/tests/unit_tests/utils_test.coffee +++ b/tests/unit_tests/utils_test.coffee @@ -61,6 +61,13 @@ context "hasChromePrefix",      assert.isFalse Utils.hasChromePrefix "data"      assert.isFalse Utils.hasChromePrefix "data :foobar" +context "isUrl", +  should "identify URLs as URLs", -> +    assert.isTrue Utils.isUrl "http://www.example.com/blah" + +  should "identify non-URLs and non-URLs", -> +    assert.isFalse Utils.isUrl "http://www.example.com/ blah" +  context "Function currying",    should "Curry correctly", ->      foo = (a, b) -> "#{a},#{b}" | 
