diff options
| -rw-r--r-- | background_scripts/main.coffee | 39 | ||||
| -rw-r--r-- | content_scripts/scroller.coffee | 25 | ||||
| -rw-r--r-- | content_scripts/ui_component.coffee | 52 | ||||
| -rw-r--r-- | content_scripts/vimium.css | 124 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 67 | ||||
| -rw-r--r-- | content_scripts/vomnibar.coffee | 286 | ||||
| -rw-r--r-- | lib/dom_utils.coffee | 8 | ||||
| -rw-r--r-- | lib/utils.coffee | 12 | ||||
| -rw-r--r-- | manifest.json | 6 | ||||
| -rw-r--r-- | pages/ui_component_server.coffee | 27 | ||||
| -rw-r--r-- | pages/vomnibar.coffee | 235 | ||||
| -rw-r--r-- | pages/vomnibar.css | 136 | ||||
| -rw-r--r-- | pages/vomnibar.html | 22 | ||||
| -rw-r--r-- | test_harnesses/vomnibar.html | 2 | ||||
| -rw-r--r-- | tests/dom_tests/chrome.coffee | 8 | ||||
| -rw-r--r-- | tests/dom_tests/dom_tests.html | 2 | ||||
| -rw-r--r-- | tests/dom_tests/vomnibar_test.coffee | 25 | ||||
| -rw-r--r-- | tests/unit_tests/test_chrome_stubs.coffee | 4 | 
18 files changed, 683 insertions, 397 deletions
| diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index cebb38ca..4c1b9ae7 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() @@ -609,24 +614,24 @@ portHandlers =    filterCompleter: filterCompleter  sendRequestHandlers = -  getCompletionKeys: getCompletionKeysRequest, -  getCurrentTabUrl: getCurrentTabUrl, -  openUrlInNewTab: openUrlInNewTab, -  openUrlInIncognito: openUrlInIncognito, -  openUrlInCurrentTab: openUrlInCurrentTab, -  openOptionsPageInNewTab: openOptionsPageInNewTab, -  registerFrame: registerFrame, -  unregisterFrame: unregisterFrame, -  frameFocused: handleFrameFocused, +  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), +  upgradeNotificationClosed: upgradeNotificationClosed +  updateScrollPosition: handleUpdateScrollPosition +  copyToClipboard: copyToClipboard +  isEnabledForUrl: isEnabledForUrl +  saveHelpDialogSettings: saveHelpDialogSettings +  selectSpecificTab: selectSpecificTab +  refreshCompleter: refreshCompleter +  createMark: Marks.create.bind(Marks)    gotoMark: Marks.goto.bind(Marks)  # Convenience function for development use. diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 09470158..889dc042 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -5,6 +5,10 @@  activatedElement = null  # 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 @@ -63,7 +67,7 @@ shouldScroll = (element, direction) ->  # 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. +# 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, @@ -80,6 +84,19 @@ findScrollableElement = (element, direction, amount, factor) ->        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 @@ -202,7 +219,7 @@ Scroller =          window.scrollBy(0, amount)        return -    activatedElement ||= document.body +    activatedElement ||= document.body and firstScrollableElement()      return unless activatedElement      # Avoid the expensive scroll calculation if it will not be used.  This reduces costs during smooth, @@ -213,8 +230,8 @@ Scroller =        CoreScroller.scroll element, direction, elementAmount    scrollTo: (direction, pos) -> -    return unless document.body or activatedElement -    activatedElement ||= document.body +    activatedElement ||= document.body and firstScrollableElement() +    return unless activatedElement      element = findScrollableElement activatedElement, direction, pos, 1      amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] diff --git a/content_scripts/ui_component.coffee b/content_scripts/ui_component.coffee new file mode 100644 index 00000000..696cb42c --- /dev/null +++ b/content_scripts/ui_component.coffee @@ -0,0 +1,52 @@ +class UIComponent +  iframeElement: null +  iframePort: null +  showing: null +  showStyle: "display: block;" +  hideStyle: "display: none;" + +  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. +      borderWas = @iframeElement.style.border +      @iframeElement.style.border = '5px solid yellow' +      setTimeout((=> @iframeElement.style.border = borderWas), 200) +    else +      @iframeElement.setAttribute "style", @showStyle +      @showing = true +    @iframeElement.focus() + +  hide: (focusWindow = true)-> +    if @showing +      @iframeElement.setAttribute "style", @hideStyle +      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 cdf47d6c..f582824a 100644 --- a/content_scripts/vimium.css +++ b/content_scripts/vimium.css @@ -271,136 +271,30 @@ 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: 2147483645;  } -#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 .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; -} - - -  div#vimiumFlash {    box-shadow: 0px 0px 4px 2px #4183C4;    padding: 1px; diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 1f116f88..ae275f0c 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -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) -> @@ -109,6 +118,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() @@ -183,6 +199,8 @@ 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. @@ -436,15 +454,16 @@ 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 -      KeydownEvents.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 +    handledKeydownEvents.push event    else if (findMode)      if (KeyboardUtils.isEscape(event)) @@ -1086,6 +1105,40 @@ 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) diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee index f1d2ccc5..0d5197a5 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", => +        @vomnibarUI.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 a0ac0bd3..8db71001 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -33,13 +33,17 @@ 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. diff --git a/lib/utils.coffee b/lib/utils.coffee index 8f6dca2a..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 -> @@ -137,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 3cd88d1e..96739d2e 100644 --- a/manifest.json +++ b/manifest.json @@ -37,6 +37,7 @@               "lib/dom_utils.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", @@ -57,5 +58,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/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..170f1b42 --- /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() + +  setRefreshInterval: (refreshInterval) -> @refreshInterval = refreshInterval + +  setForceNewTab: (forceNewTab) -> @forceNewTab = forceNewTab + +  hide: -> +    UIComponentServer.postMessage "hide" +    @reset() + +  reset: -> +    @completionList.style.display = "none" +    @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 +      @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 "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) + +    @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 = "none" + +    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..c4bc0008 --- /dev/null +++ b/pages/vomnibar.css @@ -0,0 +1,136 @@ + +/* Vomnibar CSS */ + +#vomnibar ol, #vomnibar ul { +  list-style: none; +  display: block; +} + +#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.html b/tests/dom_tests/dom_tests.html index feddafac..7b154d24 100644 --- a/tests/dom_tests/dom_tests.html +++ b/tests/dom_tests/dom_tests.html @@ -34,6 +34,7 @@      <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="../../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> @@ -52,6 +53,5 @@      <h1>Vimium Tests</h1>      <div id="output-div"></div> -    </body>  </html> 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/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index 80750337..3258bcd6 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -42,6 +42,10 @@ exports.chrome =      getAll: () -> true    storage: +    # chrome.storage.local +    local: +      set: -> +      # chrome.storage.onChanged      onChanged:        addListener: (func) -> @func = func | 
