diff options
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | background_scripts/main.coffee | 15 | ||||
| -rw-r--r-- | content_scripts/link_hints.coffee | 50 | ||||
| -rw-r--r-- | lib/settings.coffee | 88 | ||||
| -rw-r--r-- | pages/options.coffee | 3 | ||||
| -rw-r--r-- | tests/dom_tests/dom_tests.coffee | 27 | ||||
| -rw-r--r-- | tests/unit_tests/settings_test.coffee | 12 | ||||
| -rw-r--r-- | tests/unit_tests/test_chrome_stubs.coffee | 6 | 
8 files changed, 134 insertions, 70 deletions
| @@ -163,7 +163,8 @@ Release Notes  - Added <tt>\`\`</tt> to jump back to the previous position after selected jump-like movements: <br/>      (`gg`, `G`, `n`, `N`, `/` and local mark movements).  - Global marks are now persistent (across tab closes and browser sessions) and synced. -- For filtered link hints (not the default), you can now use `Tab` to select hints. +- For filtered link hints (not the default), you can now use `Tab` and `Enter` +  to select hints and hints are ordered by best match.  - Bug fixes, including:      - Bookmarklets accessed from the Vomnibar.      - Global marks on non-Windows platforms. diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index 835b8a9a..d4b14f3c 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -237,11 +237,12 @@ repeatFunction = (func, totalCount, currentCount, frameId) ->        -> repeatFunction(func, totalCount, currentCount + 1, frameId),        frameId) -moveTab = (callback, direction) -> -  chrome.tabs.getSelected(null, (tab) -> -    # Use Math.max to prevent -1 as the new index, otherwise the tab of index n will wrap to the far RHS when -    # moved left by exactly (n+1) places. -    chrome.tabs.move(tab.id, {index: Math.max(0, tab.index + direction) }, callback)) +moveTab = (count) -> +  chrome.tabs.getAllInWindow null, (tabs) -> +    pinnedCount = (tabs.filter (tab) -> tab.pinned).length +    chrome.tabs.getSelected null, (tab) -> +      chrome.tabs.move tab.id, +        index: Math.max pinnedCount, Math.min tabs.length - 1, tab.index + count  # Start action functions @@ -304,8 +305,8 @@ BackgroundCommands =      chrome.tabs.getSelected(null, (tab) ->        chrome.tabs.sendMessage(tab.id,          { name: "toggleHelpDialog", dialogHtml: helpDialogHtml(), frameId:frameId })) -  moveTabLeft: (count) -> moveTab(null, -count) -  moveTabRight: (count) -> moveTab(null, count) +  moveTabLeft: (count) -> moveTab -count +  moveTabRight: (count) -> moveTab count    nextFrame: (count,frameId) ->      chrome.tabs.getSelected null, (tab) ->        frameIdsForTab[tab.id] = cycleToFrame frameIdsForTab[tab.id], frameId, count diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee index 107a292e..15af15c5 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -478,7 +478,7 @@ class FilterHints          @labelMap[forElement] = labelText    generateHintString: (linkHintNumber) -> -    numberToHintString linkHintNumber + 1, @linkHintNumbers.toUpperCase() +    numberToHintString linkHintNumber, @linkHintNumbers.toUpperCase()    generateLinkText: (element) ->      linkText = "" @@ -512,8 +512,7 @@ class FilterHints    fillInMarkers: (hintMarkers) ->      @generateLabelMap()      DomUtils.textContent.reset() -    for marker, idx in hintMarkers -      marker.hintString = @generateHintString(idx) +    for marker in hintMarkers        linkTextObject = @generateLinkText(marker.clickableItem)        marker.linkText = linkTextObject.text        marker.showLinkText = linkTextObject.show @@ -522,7 +521,9 @@ class FilterHints      @activeHintMarker = hintMarkers[0]      @activeHintMarker?.classList.add "vimiumActiveHintMarker" -    hintMarkers +    # We use @filterLinkHints() here (although we know that all of the hints will match) to fill in the hint +    # strings.  This ensures that we always get hint strings in the same order. +    @filterLinkHints hintMarkers    getMatchingHints: (hintMarkers, tabCount = 0) ->      delay = 0 @@ -563,15 +564,46 @@ class FilterHints    # Filter link hints by search string, renumbering the hints as necessary.    filterLinkHints: (hintMarkers) -> -    idx = 0 -    linkSearchString = @linkTextKeystrokeQueue.join("").toLowerCase() - +    linkSearchString = @linkTextKeystrokeQueue.join("").trim().toLowerCase() +    do (scoreFunction = @scoreLinkHint linkSearchString) -> +      linkMarker.score = scoreFunction linkMarker for linkMarker in hintMarkers +    # The Javascript sort() method is known not to be stable.  Nevertheless, we require (and assume, here) +    # that it is deterministic.  So, if the user is typing hint characters, then hints will always end up in +    # the same order and hence with the same hint strings (because hint-string filtering happens after the +    # filtering here). +    hintMarkers = hintMarkers[..].sort (a,b) -> b.score - a.score + +    linkHintNumber = 1      for linkMarker in hintMarkers -      continue unless 0 <= linkMarker.linkText.toLowerCase().indexOf linkSearchString -      linkMarker.hintString = @generateHintString idx++ +      continue unless 0 < linkMarker.score +      linkMarker.hintString = @generateHintString linkHintNumber++        @renderMarker linkMarker        linkMarker +  # Assign a score to a filter match (higher is better).  We assign a higher score for matches at the start of +  # a word, and a considerably higher score still for matches which are whole words. +  scoreLinkHint: (linkSearchString) -> +    searchWords = linkSearchString.trim().split /\s+/ +    (linkMarker) -> +      linkWords = linkMarker.linkWords ?= linkMarker.linkText.trim().toLowerCase().split /\s+/ + +      searchWordScores = +        for searchWord in searchWords +          linkWordScores = +            for linkWord, idx in linkWords +              if linkWord == searchWord +                if idx == 0 then 8 else 6 +              else if linkWord.startsWith searchWord +                if idx == 0 then 4 else 2 +              else if 0 <= linkWord.indexOf searchWord +                1 +              else +                0 +          Math.max linkWordScores... + +      addFunc = (a,b) -> a + b +      if 0 in searchWordScores then 0 else searchWordScores.reduce addFunc, 0 +  #  # Make each hint character a span, so that we can highlight the typed characters as you type them.  # diff --git a/lib/settings.coffee b/lib/settings.coffee index ca4e77b0..437e4d45 100644 --- a/lib/settings.coffee +++ b/lib/settings.coffee @@ -1,5 +1,17 @@ +# A "setting" is a stored key/value pair.  An "option" is a setting which has a default value and whose value +# can be changed on the options page. +# +# Option values which have never been changed by the user are in Settings.defaults. +# +# Settings whose values have been changed are: +# 1. stored either in chrome.storage.sync or in chrome.storage.local (but never both), and +# 2. cached in Settings.cache; on extension pages, Settings.cache uses localStorage (so it persists). +# +# In all cases except Settings.defaults, values are stored as jsonified strings. +  Settings = +  debug: false    storage: chrome.storage.sync    cache: {}    isLoaded: false @@ -11,18 +23,21 @@ Settings =        @cache = if Utils.isBackgroundPage() then localStorage else extend {}, localStorage        @onLoaded() -    @storage.get null, (items) => -      unless chrome.runtime.lastError -        @handleUpdateFromChromeStorage key, value for own key, value of items +    chrome.storage.local.get null, (localItems) => +      localItems = {} if chrome.runtime.lastError +      @storage.get null, (syncedItems) => +        unless chrome.runtime.lastError +          @handleUpdateFromChromeStorage key, value for own key, value of extend localItems, syncedItems -      chrome.storage.onChanged.addListener (changes, area) => -        @propagateChangesFromChromeStorage changes if area == "sync" +        chrome.storage.onChanged.addListener (changes, area) => +          @propagateChangesFromChromeStorage changes if area == "sync" -      @onLoaded() +        @onLoaded()    # Called after @cache has been initialized.  On extension pages, this will be called twice, but that does    # not matter because it's idempotent.    onLoaded: -> +    @log "onLoaded: #{@onLoadedCallbacks.length} callback(s)"      @isLoaded = true      callback() while callback = @onLoadedCallbacks.pop() @@ -33,46 +48,40 @@ Settings =      @handleUpdateFromChromeStorage key, change?.newValue for own key, change of changes    handleUpdateFromChromeStorage: (key, value) -> +    @log "handleUpdateFromChromeStorage: #{key}"      # Note: value here is either null or a JSONified string.  Therefore, even falsy settings values (like      # false, 0 or "") are truthy here.  Only null is falsy.      if @shouldSyncKey key        unless value and key of @cache and @cache[key] == value -        defaultValue = @defaults[key] -        defaultValueJSON = JSON.stringify defaultValue - -        if value and value != defaultValueJSON -          # Key/value has been changed to a non-default value. -          @cache[key] = value -          @performPostUpdateHook key, JSON.parse value -        else -          # The key has been reset to its default value. -          delete @cache[key] if key of @cache -          @performPostUpdateHook key, defaultValue +        value ?= JSON.stringify @defaults[key] +        @set key, JSON.parse(value), false    get: (key) ->      console.log "WARNING: Settings have not loaded yet; using the default value for #{key}." unless @isLoaded      if key of @cache and @cache[key]? then JSON.parse @cache[key] else @defaults[key] -  set: (key, value) -> -    # Don't store the value if it is equal to the default, so we can change the defaults in the future. -    if JSON.stringify(value) == JSON.stringify @defaults[key] -      @clear key -    else -      jsonValue = JSON.stringify value -      @cache[key] = jsonValue -      if @shouldSyncKey key -        setting = {}; setting[key] = jsonValue +  set: (key, value, shouldSetInSyncedStorage = true) -> +    @cache[key] = JSON.stringify value +    @log "set: #{key} (length=#{@cache[key].length}, shouldSetInSyncedStorage=#{shouldSetInSyncedStorage})" +    if @shouldSyncKey key +      if shouldSetInSyncedStorage +        setting = {}; setting[key] = @cache[key] +        @log "   chrome.storage.sync.set(#{key})"          @storage.set setting -      @performPostUpdateHook key, value +      if Utils.isBackgroundPage() +        # Remove options installed by the "copyNonDefaultsToChromeStorage-20150717" migration; see below. +        @log "   chrome.storage.local.remove(#{key})" +        chrome.storage.local.remove key +    @performPostUpdateHook key, value    clear: (key) -> -    delete @cache[key] if @has key -    @storage.remove key if @shouldSyncKey key -    @performPostUpdateHook key, @get key +    @log "clear: #{key}" +    @set key, @defaults[key]    has: (key) -> key of @cache    use: (key, callback) -> +    @log "use: #{key} (isLoaded=#{@isLoaded})"      invokeCallback = => callback @get key      if @isLoaded then invokeCallback() else @onLoadedCallbacks.push invokeCallback @@ -80,6 +89,10 @@ Settings =    postUpdateHooks: {}    performPostUpdateHook: (key, value) -> @postUpdateHooks[key]? value +  # For development only. +  log: (args...) -> +    console.log "settings:", args... if @debug +    # Default values for all settings.    defaults:      scrollStepSize: 60 @@ -146,6 +159,7 @@ Settings =        """      newTabUrl: "chrome://newtab"      grabBackFocus: false +    regexFindMode: false      settingsVersion: Utils.getCurrentVersion()      helpDialog_showAdvancedCommands: false @@ -168,5 +182,19 @@ if Utils.isBackgroundPage()        rawQuery = Settings.get "findModeRawQuery"        chrome.storage.local.set findModeRawQueryList: (if rawQuery then [ rawQuery ] else []) +  # Migration (after 1.51, 2015/6/17). +  # Copy options with non-default values (and which are not in synced storage) to chrome.storage.local; +  # thereby making these settings accessible within content scripts. +  do (migrationKey = "copyNonDefaultsToChromeStorage-20150717") -> +    unless localStorage[migrationKey] +      chrome.storage.sync.get null, (items) -> +        unless chrome.runtime.lastError +          updates = {} +          for own key of localStorage +            if Settings.shouldSyncKey(key) and not items[key] +              updates[key] = localStorage[key] +          chrome.storage.local.set updates, -> +            localStorage[migrationKey] = not chrome.runtime.lastError +  root = exports ? window  root.Settings = Settings diff --git a/pages/options.coffee b/pages/options.coffee index ea4301a9..21e81c8f 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -229,7 +229,6 @@ initOptionsPage = ->      element.className = element.className + " example info"      element.innerHTML = "Leave empty to reset this option." -  maintainLinkHintsView()    window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled    document.addEventListener "keyup", (event) -> @@ -259,6 +258,8 @@ initOptionsPage = ->    for name, type of options      new type(name,onUpdated) +  maintainLinkHintsView() +  initPopupPage = ->    chrome.tabs.getSelected null, (tab) ->      exclusions = null diff --git a/tests/dom_tests/dom_tests.coffee b/tests/dom_tests/dom_tests.coffee index dd2f5a5d..a79735ae 100644 --- a/tests/dom_tests/dom_tests.coffee +++ b/tests/dom_tests/dom_tests.coffee @@ -212,11 +212,14 @@ context "Filtered link hints",        @linkHints.deactivateMode()      should "label the images", -> -      hintMarkers = getHintMarkers() -      assert.equal "1: alt text", hintMarkers[0].textContent.toLowerCase() -      assert.equal "2: some title", hintMarkers[1].textContent.toLowerCase() -      assert.equal "3: alt text", hintMarkers[2].textContent.toLowerCase() -      assert.equal "4", hintMarkers[3].textContent.toLowerCase() +      hintMarkers = getHintMarkers().map (marker) -> marker.textContent.toLowerCase() +      # We don't know the actual hint numbers which will be assigned, so we replace them with "N". +      hintMarkers = hintMarkers.map (str) -> str.replace /^[1-4]/, "N" +      assert.equal 4, hintMarkers.length +      assert.isTrue "N: alt text" in hintMarkers +      assert.isTrue "N: some title" in hintMarkers +      assert.isTrue "N: alt text" in hintMarkers +      assert.isTrue "N" in hintMarkers    context "Input hints", @@ -235,11 +238,15 @@ context "Filtered link hints",      should "label the input elements", ->        hintMarkers = getHintMarkers() -      assert.equal "1", hintMarkers[0].textContent.toLowerCase() -      assert.equal "2", hintMarkers[1].textContent.toLowerCase() -      assert.equal "3: a label", hintMarkers[2].textContent.toLowerCase() -      assert.equal "4: a label", hintMarkers[3].textContent.toLowerCase() -      assert.equal "5", hintMarkers[4].textContent.toLowerCase() +      hintMarkers = getHintMarkers().map (marker) -> marker.textContent.toLowerCase() +      # We don't know the actual hint numbers which will be assigned, so we replace them with "N". +      hintMarkers = hintMarkers.map (str) -> str.replace /^[1-5]/, "N" +      assert.equal 5, hintMarkers.length +      assert.isTrue "N" in hintMarkers +      assert.isTrue "N" in hintMarkers +      assert.isTrue "N: a label" in hintMarkers +      assert.isTrue "N: a label" in hintMarkers +      assert.isTrue "N" in hintMarkers  context "Input focus", diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee index 08145190..6270ae3e 100644 --- a/tests/unit_tests/settings_test.coffee +++ b/tests/unit_tests/settings_test.coffee @@ -27,12 +27,6 @@ context "settings",      Settings.set 'scrollStepSize', 20      assert.equal Settings.get('scrollStepSize'), 20 -  should "not store values equal to the default", -> -    Settings.set 'scrollStepSize', 20 -    assert.isTrue Settings.has 'scrollStepSize' -    Settings.set 'scrollStepSize', 60 -    assert.isFalse Settings.has 'scrollStepSize' -    should "revert to defaults if no key is stored", ->      Settings.set 'scrollStepSize', 20      Settings.clear 'scrollStepSize' @@ -55,7 +49,7 @@ context "synced settings",      Settings.set 'scrollStepSize', 20      assert.equal Settings.get('scrollStepSize'), 20      Settings.propagateChangesFromChromeStorage { scrollStepSize: { newValue: "60" } } -    assert.isFalse Settings.has 'scrollStepSize' +    assert.equal Settings.get('scrollStepSize'), 60    should "propagate non-default values from synced storage", ->      chrome.storage.sync.set { scrollStepSize: JSON.stringify(20) } @@ -64,12 +58,12 @@ context "synced settings",    should "propagate default values from synced storage", ->      Settings.set 'scrollStepSize', 20      chrome.storage.sync.set { scrollStepSize: JSON.stringify(60) } -    assert.isFalse Settings.has 'scrollStepSize' +    assert.equal Settings.get('scrollStepSize'), 60    should "clear a setting from synced storage", ->      Settings.set 'scrollStepSize', 20      chrome.storage.sync.remove 'scrollStepSize' -    assert.isFalse Settings.has 'scrollStepSize' +    assert.equal Settings.get('scrollStepSize'), 60    should "trigger a postUpdateHook", ->      message = "Hello World" diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index fe2fc298..c6a56521 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -57,9 +57,9 @@ exports.chrome =    storage:      # chrome.storage.local      local: -      get: -> -      set: -> -      remove: -> +      get: (_, callback) -> callback?() +      set: (_, callback) -> callback?() +      remove: (_, callback) -> callback?()      # chrome.storage.onChanged      onChanged: | 
