diff options
| -rw-r--r-- | CONTRIBUTING.md | 32 | ||||
| -rw-r--r-- | background_scripts/commands.coffee | 4 | ||||
| -rw-r--r-- | background_scripts/exclusions.coffee | 70 | ||||
| -rw-r--r-- | background_scripts/main.coffee | 99 | ||||
| -rw-r--r-- | background_scripts/settings.coffee | 15 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 75 | ||||
| -rw-r--r-- | content_scripts/vomnibar.coffee | 2 | ||||
| -rw-r--r-- | icons/browser_action_partial.png | bin | 0 -> 34384 bytes | |||
| -rw-r--r-- | icons/icon48partial.png | bin | 0 -> 3815 bytes | |||
| -rw-r--r-- | manifest.json | 1 | ||||
| -rw-r--r-- | pages/options.coffee | 262 | ||||
| -rw-r--r-- | pages/options.html | 63 | ||||
| -rw-r--r-- | pages/popup.coffee | 90 | ||||
| -rw-r--r-- | pages/popup.html | 24 | ||||
| -rw-r--r-- | tests/unit_tests/exclusion_test.coffee | 73 | ||||
| -rw-r--r-- | tests/unit_tests/test_chrome_stubs.coffee | 43 |
16 files changed, 656 insertions, 197 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03ac26e9..9382a020 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,12 +59,44 @@ reports: * We follow two major differences from this style guide: * Wrap lines at 110 characters instead of 80. * Use double-quoted strings by default. + * When writing comments, uppercase the first letter of your sentence, and put a period at the end. + * If you have a short conditional, feel free to put it on one line: + + # No + if i < 10 + return + + # Yes + return if i < 10 ## Pull Requests 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. +## What makes for a good feature request/contribution to Vimium? + +Good features: + +* Useful for lots of Vimium users +* Require no/little documentation +* Useful without configuration +* Intuitive or leverage strong convention from Vim +* Work robustly on most/all sites + +Less-good features: + +* Are very niche, and so aren't useful for many Vimium users +* Require explanation +* Require configuration before it becomes useful +* Unintuitive, or they don't leverage a strong convention from Vim +* Might be flaky and don't work in many cases + +We use these guidelines, in addition to the code complexity, when deciding whether to merge in a pull request. + +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. diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index 45a220dc..825160d8 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -258,8 +258,8 @@ commandDescriptions = moveTabToNewWindow: ["Move tab to new window", { background: true }] togglePinTab: ["Pin/unpin current tab", { background: true }] - closeTabsToLeft: ["Close tabs on the left", {background: true, noRepeat: true}] - closeTabsToRight: ["Close tabs on the right", {background: true, noRepeat: true}] + closeTabsOnLeft: ["Close tabs on the left", {background: true, noRepeat: true}] + closeTabsOnRight: ["Close tabs on the right", {background: true, noRepeat: true}] closeOtherTabs: ["Close all other tabs", {background: true, noRepeat: true}] moveTabLeft: ["Move tab to the left", { background: true, passCountToFunction: true }] diff --git a/background_scripts/exclusions.coffee b/background_scripts/exclusions.coffee new file mode 100644 index 00000000..3a8ef1e7 --- /dev/null +++ b/background_scripts/exclusions.coffee @@ -0,0 +1,70 @@ +root = exports ? window + +RegexpCache = + cache: {} + get: (pattern) -> + if regexp = @cache[pattern] + regexp + else + @cache[pattern] = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$") + +# The Exclusions class manages the exclusion rule setting. +# An exclusion is an object with two attributes: pattern and passKeys. +# The exclusions are an array of such objects (because the order matters). + +root.Exclusions = Exclusions = + + rules: Settings.get("exclusionRules") + + # Return the first exclusion rule matching the URL, or null. + getRule: (url) -> + for rule in @rules + return rule if url.match(RegexpCache.get(rule.pattern)) + return null + + setRules: (rules) -> + # Callers map a rule to null to have it deleted, and rules without a pattern are useless. + @rules = rules.filter (rule) -> rule and rule.pattern + Settings.set("exclusionRules", @rules) + + postUpdateHook: (rules) -> + @rules = rules + + # Update an existing rule or add a new rule. + updateOrAdd: (newRule) -> + seen = false + @rules.push(newRule) + @setRules @rules.map (rule) -> + if rule.pattern == newRule.pattern + if seen then null else seen = newRule + else + rule + + remove: (pattern) -> + @setRules(@rules.filter((rule) -> rule and rule.pattern != pattern)) + +# Development and debug only. +# Enable this (temporarily) to restore legacy exclusion rules from backup. +if false and Settings.has("excludedUrlsBackup") + Settings.clear("exclusionRules") + Settings.set("excludedUrls", Settings.get("excludedUrlsBackup")) + +if not Settings.has("exclusionRules") and Settings.has("excludedUrls") + # Migration from the legacy representation of exclusion rules. + # + # In Vimium 1.45 and in github/master on 27 August, 2014, exclusion rules are represented by the setting: + # excludedUrls: "http*://www.google.com/reader/*\nhttp*://mail.google.com/* jk" + # + # The new (equivalent) settings is: + # exclusionRules: [ { pattern: "http*://www.google.com/reader/*", passKeys: "" }, { pattern: "http*://mail.google.com/*", passKeys: "jk" } ] + + parseLegacyRules = (lines) -> + for line in lines.trim().split("\n").map((line) -> line.trim()) + if line.length and line.indexOf("#") != 0 and line.indexOf('"') != 0 + parse = line.split(/\s+/) + { pattern: parse[0], passKeys: parse[1..].join("") } + + Exclusions.setRules(parseLegacyRules(Settings.get("excludedUrls"))) + # We'll keep a backup of the "excludedUrls" setting, just in case. + Settings.set("excludedUrlsBackup", Settings.get("excludedUrls")) if not Settings.has("excludedUrlsBackup") + Settings.clear("excludedUrls") diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index 431d9a31..352cfa48 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -69,30 +69,31 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) -> getCurrentTabUrl = (request, sender) -> sender.tab.url # -# Checks the user's preferences in local storage to determine if Vimium is enabled for the given URL. +# Checks the user's preferences in local storage to determine if Vimium is enabled for the given URL, and +# whether any keys should be passed through to the underlying page. # -isEnabledForUrl = (request) -> - # excludedUrls are stored as a series of URL expressions separated by newlines. - excludedUrls = Settings.get("excludedUrls").split("\n") - isEnabled = true - for url in excludedUrls - # The user can add "*" to the URL which means ".*" - regexp = new RegExp("^" + url.replace(/\*/g, ".*") + "$") - isEnabled = false if request.url.match(regexp) - { isEnabledForUrl: isEnabled } - -# Called by the popup UI. Strips leading/trailing whitespace and ignores empty strings. -root.addExcludedUrl = (url) -> - return unless url = url.trim() - - excludedUrls = Settings.get("excludedUrls") - return if excludedUrls.indexOf(url) >= 0 - - excludedUrls += "\n" + url - Settings.set("excludedUrls", excludedUrls) - - chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, - (tabs) -> updateActiveState(tabs[0].id)) +root.isEnabledForUrl = isEnabledForUrl = (request) -> + rule = Exclusions.getRule(request.url) + { + rule: rule + isEnabledForUrl: not rule or rule.passKeys + passKeys: rule?.passKeys or "" + } + +# Called by the popup UI. +# If the URL pattern matches an existing rule, then the existing rule is updated. Otherwise, a new rule is created. +root.addExclusionRule = (pattern,passKeys) -> + if pattern = pattern.trim() + Exclusions.updateOrAdd({ pattern: pattern, passKeys: passKeys }) + chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, + (tabs) -> updateActiveState(tabs[0].id)) + +# Called by the popup UI. Remove all existing exclusion rules with this pattern. +root.removeExclusionRule = (pattern) -> + if pattern = pattern.trim() + Exclusions.remove(pattern) + chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, + (tabs) -> updateActiveState(tabs[0].id)) saveHelpDialogSettings = (request) -> Settings.set("helpDialog_showAdvancedCommands", request.showAdvancedCommands) @@ -346,32 +347,36 @@ updateOpenTabs = (tab) -> # Frames are recreated on refresh delete framesForTab[tab.id] -# Updates the browserAction icon to indicated whether Vimium is enabled or disabled on the current page. -# Also disables Vimium if it is currently enabled but should be disabled according to the url blacklist. +setBrowserActionIcon = (tabId,path) -> + chrome.browserAction.setIcon({ tabId: tabId, path: path }) + +# Updates the browserAction icon to indicate whether Vimium is enabled or disabled on the current page. +# Also propagates new enabled/disabled/passkeys state to active window, if necessary. # This lets you disable Vimium on a page without needing to reload. -# -# Three situations are considered: -# 1. Active tab is disabled -> disable icon -# 2. Active tab is enabled and should be enabled -> enable icon -# 3. Active tab is enabled but should be disabled -> disable icon and disable vimium updateActiveState = (tabId) -> enabledIcon = "icons/browser_action_enabled.png" disabledIcon = "icons/browser_action_disabled.png" - chrome.tabs.get(tabId, (tab) -> - # Default to disabled state in case we can't connect to Vimium, primarily for the "New Tab" page. - chrome.browserAction.setIcon({ path: disabledIcon }) - chrome.tabs.sendMessage(tabId, { name: "getActiveState" }, (response) -> - isCurrentlyEnabled = (response? && response.enabled) - shouldBeEnabled = isEnabledForUrl({url: tab.url}).isEnabledForUrl - - if (isCurrentlyEnabled) - if (shouldBeEnabled) - chrome.browserAction.setIcon({ path: enabledIcon }) + partialIcon = "icons/browser_action_partial.png" + chrome.tabs.get tabId, (tab) -> + chrome.tabs.sendMessage tabId, { name: "getActiveState" }, (response) -> + if response + isCurrentlyEnabled = response.enabled + currentPasskeys = response.passKeys + config = isEnabledForUrl({url: tab.url}) + enabled = config.isEnabledForUrl + passKeys = config.passKeys + if (enabled and passKeys) + setBrowserActionIcon(tabId,partialIcon) + else if (enabled) + setBrowserActionIcon(tabId,enabledIcon) else - chrome.browserAction.setIcon({ path: disabledIcon }) - chrome.tabs.sendMessage(tabId, { name: "disableVimium" }) + setBrowserActionIcon(tabId,disabledIcon) + # Propagate the new state only if it has changed. + if (isCurrentlyEnabled != enabled || currentPasskeys != passKeys) + chrome.tabs.sendMessage(tabId, { name: "setState", enabled: enabled, passKeys: passKeys }) else - chrome.browserAction.setIcon({ path: disabledIcon }))) + # We didn't get a response from the front end, so Vimium isn't running. + setBrowserActionIcon(tabId,disabledIcon) handleUpdateScrollPosition = (request, sender) -> updateScrollPosition(sender.tab, request.scrollX, request.scrollY) @@ -500,6 +505,14 @@ handleKeyDown = (request, port) -> console.log("checking keyQueue: [", keyQueue + key, "]") keyQueue = checkKeyQueue(keyQueue + key, port.sender.tab.id, request.frameId) console.log("new KeyQueue: " + keyQueue) + # Tell the content script whether there are keys in the queue. + # FIXME: There is a race condition here. The behaviour in the content script depends upon whether this message gets + # back there before or after the next keystroke. + # That being said, I suspect there are other similar race conditions here, for example in checkKeyQueue(). + # Steve (23 Aug, 14). + chrome.tabs.sendMessage(port.sender.tab.id, + name: "currentKeyQueue", + keyQueue: keyQueue) checkKeyQueue = (keysToCheck, tabId, frameId) -> refreshedCompletionKeys = false diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index 175f3262..7150fcba 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -35,6 +35,9 @@ root.Settings = Settings = searchEngines: (value) -> root.Settings.parseSearchEngines value + exclusionRules: (value) -> + root.Exclusions.postUpdateHook value + # postUpdateHooks convenience wrapper performPostUpdateHook: (key, value) -> @postUpdateHooks[key] value if @postUpdateHooks[key] @@ -58,6 +61,7 @@ root.Settings = Settings = # or strings defaults: scrollStepSize: 60 + keyMappings: "# Insert your prefered key mappings here." linkHintCharacters: "sadfjklewcmpgh" linkHintNumbers: "0123456789" filterLinkHints: false @@ -81,10 +85,13 @@ root.Settings = Settings = div > .vimiumHintMarker > .matchingCharacter { } """ - excludedUrls: - """ - http*://mail.google.com/* - """ + # Default exclusion rules. + exclusionRules: + [ + # Disable Vimium on Gmail. + { pattern: "http*://mail.google.com/*", passKeys: "" } + ] + # NOTE: If a page contains both a single angle-bracket link and a double angle-bracket link, then in # most cases the single bracket link will be "prev/next page" and the double bracket link will be # "first/last page", so we put the single bracket first in the pattern string so that it gets searched diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 565c9e61..6db0d830 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -13,8 +13,13 @@ findModeQueryHasResults = false findModeAnchorNode = null isShowingHelpDialog = false keyPort = null -# Users can disable Vimium on URL patterns via the settings page. +# Users can disable Vimium on URL patterns via the settings page. The following two variables +# (isEnabledForUrl and passKeys) control Vimium's enabled/disabled behaviour. +# "passKeys" are keys which would normally be handled by Vimium, but are disabled on this tab, and therefore +# are passed through to the underlying page. isEnabledForUrl = true +passKeys = null +keyQueue = null # The user's operating system. currentCompletionKeys = null validFirstKeys = null @@ -115,42 +120,47 @@ initializePreDomReady = -> getScrollPosition: -> scrollX: window.scrollX, scrollY: window.scrollY setScrollPosition: (request) -> setScrollPosition request.scrollX, request.scrollY executePageCommand: executePageCommand - getActiveState: -> { enabled: isEnabledForUrl } - disableVimium: disableVimium + getActiveState: -> { enabled: isEnabledForUrl, passKeys: passKeys } + setState: setState + currentKeyQueue: (request) -> keyQueue = request.keyQueue chrome.runtime.onMessage.addListener (request, sender, sendResponse) -> - # in the options page, we will receive requests from both content and background scripts. ignore those + # In the options page, we will receive requests from both content and background scripts. ignore those # from the former. return if sender.tab and not sender.tab.url.startsWith 'chrome-extension://' - return unless isEnabledForUrl or request.name == 'getActiveState' + return unless isEnabledForUrl or request.name == 'getActiveState' or request.name == 'setState' + # These requests are delivered to the options page, but there are no handlers there. + return if request.handler == "registerFrame" or request.handler == "frameFocused" sendResponse requestHandlers[request.name](request, sender) # Ensure the sendResponse callback is freed. false -# -# This is called once the background page has told us that Vimium should be enabled for the current URL. -# -initializeWhenEnabled = -> - document.addEventListener("keydown", onKeydown, true) - document.addEventListener("keypress", onKeypress, true) - document.addEventListener("keyup", onKeyup, true) - document.addEventListener("focus", onFocusCapturePhase, true) - document.addEventListener("blur", onBlurCapturePhase, true) - document.addEventListener("DOMActivate", onDOMActivate, true) - enterInsertModeIfElementIsFocused() +# Wrapper to install event listeners. Syntactic sugar. +installListener = (event, callback) -> document.addEventListener(event, callback, true) # -# Used to disable Vimium without needing to reload the page. -# This is called if the current page's url is blacklisted using the popup UI. +# This is called once the background page has told us that Vimium should be enabled for the current URL. +# We enable/disable Vimium by toggling isEnabledForUrl. The alternative, installing or uninstalling +# listeners, is error prone. It's more difficult to keep track of the state. # -disableVimium = -> - document.removeEventListener("keydown", onKeydown, true) - document.removeEventListener("keypress", onKeypress, true) - document.removeEventListener("keyup", onKeyup, true) - document.removeEventListener("focus", onFocusCapturePhase, true) - document.removeEventListener("blur", onBlurCapturePhase, true) - document.removeEventListener("DOMActivate", onDOMActivate, true) - isEnabledForUrl = false +installedListeners = false +initializeWhenEnabled = (newPassKeys) -> + isEnabledForUrl = true + passKeys = newPassKeys + if (!installedListeners) + installListener "keydown", (event) -> if isEnabledForUrl then onKeydown(event) else true + installListener "keypress", (event) -> if isEnabledForUrl then onKeypress(event) else true + installListener "keyup", (event) -> if isEnabledForUrl then onKeyup(event) else true + installListener "focus", (event) -> if isEnabledForUrl then onFocusCapturePhase(event) else true + installListener "blur", (event) -> if isEnabledForUrl then onBlurCapturePhase(event) + installListener "DOMActivate", (event) -> if isEnabledForUrl then onDOMActivate(event) + enterInsertModeIfElementIsFocused() + installedListeners = true + +setState = (request) -> + isEnabledForUrl = request.enabled + passKeys = request.passKeys + initializeWhenEnabled(passKeys) if isEnabledForUrl and !installedListeners # # The backend needs to know which frame has focus. @@ -321,6 +331,12 @@ extend window, false +# Decide whether this keyChar should be passed to the underlying page. +# Keystrokes are *never* considered passKeys if the keyQueue is not empty. So, for example, if 't' is a +# passKey, then 'gt' and '99t' will neverthless be handled by vimium. +isPassKey = ( keyChar ) -> + return !keyQueue and passKeys and 0 <= passKeys.indexOf(keyChar) + handledKeydownEvents = [] # @@ -349,6 +365,8 @@ onKeypress = (event) -> handleKeyCharForFindMode(keyChar) DomUtils.suppressEvent(event) else if (!isInsertMode() && !findMode) + if (isPassKey keyChar) + return undefined if (currentCompletionKeys.indexOf(keyChar) != -1) DomUtils.suppressEvent(event) @@ -431,6 +449,9 @@ onKeydown = (event) -> else if (KeyboardUtils.isEscape(event)) keyPort.postMessage({ keyChar:"<ESC>", frameId:frameId }) + else if isPassKey KeyboardUtils.getKeyChar(event) + return undefined + # Added to prevent propagating this event to other listeners if it's one that'll trigger a Vimium command. # The goal is to avoid the scenario where Google Instant Search uses every keydown event to dump us # back into the search box. As a side effect, this should also prevent overriding by other sites. @@ -466,7 +487,7 @@ checkIfEnabledForUrl = -> chrome.runtime.sendMessage { handler: "isEnabledForUrl", url: url }, (response) -> isEnabledForUrl = response.isEnabledForUrl if (isEnabledForUrl) - initializeWhenEnabled() + initializeWhenEnabled(response.passKeys) else if (HUD.isReady()) # Quickly hide any HUD we might already be showing, e.g. if we entered insert mode on page load. HUD.hide() diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee index 6997d387..9c13cd6d 100644 --- a/content_scripts/vomnibar.coffee +++ b/content_scripts/vomnibar.coffee @@ -111,10 +111,12 @@ class VomnibarUI 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, diff --git a/icons/browser_action_partial.png b/icons/browser_action_partial.png Binary files differnew file mode 100644 index 00000000..e713f005 --- /dev/null +++ b/icons/browser_action_partial.png diff --git a/icons/icon48partial.png b/icons/icon48partial.png Binary files differnew file mode 100644 index 00000000..088099b1 --- /dev/null +++ b/icons/icon48partial.png diff --git a/manifest.json b/manifest.json index 9a4e0167..48681675 100644 --- a/manifest.json +++ b/manifest.json @@ -13,6 +13,7 @@ "lib/clipboard.js", "background_scripts/sync.js", "background_scripts/settings.js", + "background_scripts/exclusions.js", "background_scripts/completion.js", "background_scripts/marks.js", "background_scripts/main.js" diff --git a/pages/options.coffee b/pages/options.coffee index d73d8f15..7f374f5d 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -1,36 +1,148 @@ -$ = (id) -> document.getElementById id +$ = (id) -> document.getElementById id bgSettings = chrome.extension.getBackgroundPage().Settings -editableFields = [ "scrollStepSize", "excludedUrls", "linkHintCharacters", "linkHintNumbers", - "userDefinedLinkHintCss", "keyMappings", "filterLinkHints", "previousPatterns", - "nextPatterns", "hideHud", "regexFindMode", "searchUrl", "searchEngines"] - -canBeEmptyFields = ["excludedUrls", "keyMappings", "userDefinedLinkHintCss", "searchEngines"] - -document.addEventListener "DOMContentLoaded", -> - populateOptions() - - for field in editableFields - $(field).addEventListener "keyup", onOptionKeyup, false - $(field).addEventListener "change", enableSaveButton, false - $(field).addEventListener "change", onDataLoaded, false - - $("advancedOptionsLink").addEventListener "click", toggleAdvancedOptions, false - $("showCommands").addEventListener "click", (-> - showHelpDialog chrome.extension.getBackgroundPage().helpDialogHtml(true, true, "Command Listing"), frameId - ), false - document.getElementById("restoreSettings").addEventListener "click", restoreToDefaults - document.getElementById("saveOptions").addEventListener "click", saveOptions +# +# Class hierarchy for various types of option. +class Option + # Base class for all option classes. + # Abstract. Option does not define @populateElement or @readValueFromElement. + + # Static. Array of all options. + @all = [] + + constructor: (field,enableSaveButton) -> + @field = field + @element = $(@field) + @element.addEventListener "change", enableSaveButton + @fetch() + Option.all.push @ + + # Fetch a setting from localStorage, remember the @previous value and populate the DOM element. + # Return the fetched value. + fetch: -> + @populateElement @previous = bgSettings.get @field + @previous + + # Write this option's new value back to localStorage, if necessary. + save: -> + value = @readValueFromElement() + if not @areEqual value, @previous + bgSettings.set @field, @previous = value + bgSettings.performPostUpdateHook @field, value + + # Compare values; this is overridden by sub-classes. + areEqual: (a,b) -> a == b + + restoreToDefault: -> + bgSettings.clear @field + @fetch() + + # Abstract method; only implemented in sub-classes. + # Populate the option's DOM element (@element) with the setting's current value. + # populateElement: (value) -> DO_SOMETHING + + # Abstract method; only implemented in sub-classes. + # Extract the setting's new value from the option's DOM element (@element). + # readValueFromElement: -> RETURN_SOMETHING + +class NumberOption extends Option + populateElement: (value) -> @element.value = value + readValueFromElement: -> parseFloat @element.value + +class TextOption extends Option + populateElement: (value) -> @element.value = value + readValueFromElement: -> @element.value.trim() + +class NonEmptyTextOption extends Option + populateElement: (value) -> @element.value = value + # If the new value is not empty, then return it. Otherwise, restore the default value. + readValueFromElement: -> if value = @element.value.trim() then value else @restoreToDefault() + +class CheckBoxOption extends Option + populateElement: (value) -> @element.checked = value + readValueFromElement: -> @element.checked + +class ExclusionRulesOption extends Option + constructor: (args...) -> + super(args...) + $("exclusionAddButton").addEventListener "click", (event) => + @appendRule { pattern: "", passKeys: "" } + @maintainExclusionMargin() + # Focus the pattern element in the new rule. + @element.children[@element.children.length-1].children[0].children[0].focus() + # Scroll the new rule into view. + exclusionScrollBox = $("exclusionScrollBox") + 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) -> + content = document.querySelector('#exclusionRuleTemplate').content + row = document.importNode content, true + + for field in ["pattern", "passKeys"] + element = row.querySelector ".#{field}" + element.value = rule[field] + for event in [ "keyup", "change" ] + element.addEventListener event, enableSaveButton + + remove = row.querySelector ".exclusionRemoveButton" + remove.addEventListener "click", (event) => + row = event.target.parentNode.parentNode + row.parentNode.removeChild row + enableSaveButton() + @maintainExclusionMargin() + + @element.appendChild row + + readValueFromElement: -> + rules = + for element in @element.children + pattern = element.children[0].firstChild.value.trim() + passKeys = element.children[1].firstChild.value.trim() + { pattern: pattern, passKeys: passKeys } + rules.filter (rule) -> rule.pattern + + areEqual: (a,b) -> + # Flatten each list of rules to a newline-separated string representation, and then use string equality. + # This is correct because patterns and passKeys cannot themselves contain newlines. + 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" -window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled +saveOptions = -> + Option.all.map (option) -> option.save() + $("saveOptions").disabled = true -onOptionKeyup = (event) -> - if (event.target.getAttribute("type") isnt "checkbox" and - event.target.getAttribute("savedValue") isnt event.target.value) - enableSaveButton() +restoreToDefaults = -> + return unless confirm "Are you sure you want to permanently return all of Vimium's settings to their defaults?" + Option.all.map (option) -> option.restoreToDefault() + maintainLinkHintsView() + $("saveOptions").disabled = true -onDataLoaded = -> +# Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints". +maintainLinkHintsView = -> hide = (el) -> el.parentNode.parentNode.style.display = "none" show = (el) -> el.parentNode.parentNode.style.display = "table-row" if $("filterLinkHints").checked @@ -40,64 +152,48 @@ onDataLoaded = -> show $("linkHintCharacters") hide $("linkHintNumbers") -enableSaveButton = -> - $("saveOptions").removeAttribute "disabled" - -# Saves options to localStorage. -saveOptions = -> - - # If the value is unchanged from the default, delete the preference from localStorage; this gives us - # the freedom to change the defaults in the future. - for fieldName in editableFields - field = $(fieldName) - switch field.getAttribute("type") - when "checkbox" - fieldValue = field.checked - when "number" - fieldValue = parseFloat field.value +toggleAdvancedOptions = + do (advancedMode=false) -> + (event) -> + if advancedMode + $("advancedOptions").style.display = "none" + $("advancedOptionsLink").innerHTML = "Show advanced options…" else - fieldValue = field.value.trim() - field.value = fieldValue - - # If it's empty and not a field that we allow to be empty, restore to the default value - if not fieldValue and canBeEmptyFields.indexOf(fieldName) is -1 - bgSettings.clear fieldName - fieldValue = bgSettings.get(fieldName) - else - bgSettings.set fieldName, fieldValue - $(fieldName).value = fieldValue - $(fieldName).setAttribute "savedValue", fieldValue - bgSettings.performPostUpdateHook fieldName, fieldValue + $("advancedOptions").style.display = "table-row-group" + $("advancedOptionsLink").innerHTML = "Hide advanced options" + advancedMode = !advancedMode + event.preventDefault() - $("saveOptions").disabled = true +activateHelpDialog = -> + showHelpDialog chrome.extension.getBackgroundPage().helpDialogHtml(true, true, "Command Listing"), frameId -# Restores select box state to saved value from localStorage. -populateOptions = -> - for field in editableFields - val = bgSettings.get(field) or "" - setFieldValue $(field), val - onDataLoaded() +# +# Initialization. +document.addEventListener "DOMContentLoaded", -> -restoreToDefaults = -> - for field in editableFields - val = bgSettings.defaults[field] or "" - setFieldValue $(field), val - onDataLoaded() - enableSaveButton() - -setFieldValue = (field, value) -> - unless field.getAttribute("type") is "checkbox" - field.value = value - field.setAttribute "savedValue", value - else - field.checked = value + # Populate options. The constructor adds each new object to "Option.all". + new type(name,enableSaveButton) for name, type of { + exclusionRules: ExclusionRulesOption + filterLinkHints: CheckBoxOption + hideHud: CheckBoxOption + keyMappings: TextOption + linkHintCharacters: NonEmptyTextOption + linkHintNumbers: NonEmptyTextOption + nextPatterns: NonEmptyTextOption + previousPatterns: NonEmptyTextOption + regexFindMode: CheckBoxOption + scrollStepSize: NumberOption + searchEngines: TextOption + searchUrl: NonEmptyTextOption + userDefinedLinkHintCss: TextOption + } + + $("saveOptions").addEventListener "click", saveOptions + $("restoreSettings").addEventListener "click", restoreToDefaults + $("advancedOptionsLink").addEventListener "click", toggleAdvancedOptions + $("showCommands").addEventListener "click", activateHelpDialog + $("filterLinkHints").addEventListener "click", maintainLinkHintsView + + maintainLinkHintsView() + window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled -toggleAdvancedOptions = do (advancedMode=false) -> (event) -> - if advancedMode - $("advancedOptions").style.display = "none" - $("advancedOptionsLink").innerHTML = "Show advanced options…" - else - $("advancedOptions").style.display = "table-row-group" - $("advancedOptionsLink").innerHTML = "Hide advanced options" - advancedMode = !advancedMode - event.preventDefault() diff --git a/pages/options.html b/pages/options.html index b71625e8..fb904316 100644 --- a/pages/options.html +++ b/pages/options.html @@ -109,11 +109,6 @@ width: 40px; margin-right: 3px; } - textarea#excludedUrls { - margin-top: 5px; - width: 100%; - min-height: 100px; - } textarea#userDefinedLinkHintCss { width: 100%;; min-height: 100px; @@ -178,6 +173,31 @@ padding: 15px 0; border-top: 1px solid #eee; } + /* Ids and classes for rendering exclusionRules */ + #exclusionScrollBox { + overflow: scroll; + overflow-x: hidden; + overflow-y: auto; + height: 225px; + border: 1px solid #bfbfbf; + border-radius: 2px; + color: #444; + } + input.pattern, input.passKeys { + font-family: Consolas, "Liberation Mono", Courier, monospace; + font-size: 14px; + } + .pattern { + width: 250px; + } + .passKeys { + width: 120px; + } + #exclusionAddButton { + float: right; + margin-top: 5px; + margin-right: 0px; + } </style> <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> @@ -196,16 +216,31 @@ </td> </tr> <tr> - <td colspan="3"> - Excluded URLs<br/> - <div class="help"> - <div class="example"> - e.g. http*://mail.google.com/*<br/> - This will disable Vimium on Gmail.<br/><br/> - Enter one URL per line.<br/> - </div> + <td>Excluded URLs<br/>and keys</td> + <td> + <div class="help"> + <div class="example"> + <p> + 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. + </p> + <p> + The right column contains keys which Vimium would would normally handle, but should instead be passed through to the underlying web page (for pages matching the corresponding pattern). If empty, then Vimium is wholly disabled. + </p> </div> - <textarea id="excludedUrls"></textarea> + </div> + <div> + <div id="exclusionScrollBox"> + <table id="exclusionRules"></table> + <template id="exclusionRuleTemplate"> + <tr> + <td><input/ type="text" class="pattern" placeholder="URL pattern"></td> + <td><input/ type="text" class="passKeys" placeholder="Exclude keys"></td> + <td><input/ type="button" class="exclusionRemoveButton" value="✖"></td> + </tr> + </template> + </div> + <button id="exclusionAddButton">Add Rule</button> + </div> </td> </tr> <tbody id='advancedOptions'> diff --git a/pages/popup.coffee b/pages/popup.coffee index 6d7afafc..ecf683e5 100644 --- a/pages/popup.coffee +++ b/pages/popup.coffee @@ -1,16 +1,88 @@ + +originalRule = undefined +originalPattern = undefined +originalPassKeys = undefined + onLoad = -> document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html") chrome.tabs.getSelected null, (tab) -> - # 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 - document.getElementById("popupInput").value = domain + "*" + isEnabled = chrome.extension.getBackgroundPage().isEnabledForUrl(url: tab.url) + # Check if we have an existing exclusing rule for this page. + if isEnabled.rule + originalRule = isEnabled.rule + originalPattern = originalRule.pattern + originalPassKeys = originalRule.passKeys + else + # 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 + originalPassKeys = "" + document.getElementById("popupPattern").value = originalPattern + document.getElementById("popupPassKeys").value = originalPassKeys + onChange() + +onChange = -> + # As the text in the popup's input elements is changed, update the the popup's buttons accordingly. + # Aditionally, enable and disable those buttons as appropriate. + pattern = document.getElementById("popupPattern").value.trim() + passKeys = document.getElementById("popupPassKeys").value.trim() + popupExclude = document.getElementById("popupExclude") + + document.getElementById("popupRemove").disabled = + not (originalRule and pattern == originalPattern) + + if originalRule and pattern == originalPattern and passKeys == originalPassKeys + popupExclude.disabled = true + popupExclude.value = "Update Rule" + + else if originalRule and pattern == originalPattern + popupExclude.disabled = false + popupExclude.value = "Update Rule" + + else if originalRule + popupExclude.disabled = false + popupExclude.value = "Add Rule" + + else if pattern + popupExclude.disabled = false + popupExclude.value = "Add Rule" -onExcludeUrl = (e) -> - url = document.getElementById("popupInput").value - chrome.extension.getBackgroundPage().addExcludedUrl url - document.getElementById("excludeConfirm").setAttribute "style", "display: inline-block" + else + popupExclude.disabled = true + popupExclude.value = "Add Rule" + +showMessage = do -> + timer = null + + hideConfirmationMessage = -> + document.getElementById("confirmationMessage").setAttribute "style", "display: none" + timer = null + + (message) -> + document.getElementById("confirmationMessage").setAttribute "style", "display: inline-block" + document.getElementById("confirmationMessage").innerHTML = message + clearTimeout(timer) if timer + timer = setTimeout(hideConfirmationMessage,2000) + +addExclusionRule = -> + pattern = document.getElementById("popupPattern").value.trim() + passKeys = document.getElementById("popupPassKeys").value.trim() + chrome.extension.getBackgroundPage().addExclusionRule pattern, passKeys + showMessage("Updated.") + onLoad() + +removeExclusionRule = -> + pattern = document.getElementById("popupPattern").value.trim() + chrome.extension.getBackgroundPage().removeExclusionRule pattern + showMessage("Removed.") + onLoad() document.addEventListener "DOMContentLoaded", -> - document.getElementById("popupButton").addEventListener "click", onExcludeUrl, false + document.getElementById("popupExclude").addEventListener "click", addExclusionRule, false + document.getElementById("popupRemove").addEventListener "click", removeExclusionRule, false + for field in ["popupPattern", "popupPassKeys"] + for event in ["keyup", "change"] + document.getElementById(field).addEventListener event, onChange, false onLoad() diff --git a/pages/popup.html b/pages/popup.html index 8ccf7126..86982eae 100644 --- a/pages/popup.html +++ b/pages/popup.html @@ -6,17 +6,22 @@ padding: 0px; } - #vimiumPopup { width: 300px; } + #vimiumPopup { width: 400px; } #excludeControls { padding: 10px; } - #popupInput { - width: 160px; + #popupPattern, #popupPassKeys { + margin: 5px; + width: 330px; + /* Match the corresponding font and font size used on the options page. */ + /* TODO (smblott): Match other styles from the options page. */ + font-family: Consolas, "Liberation Mono", Courier, monospace; + font-size: 14px; } - #excludeConfirm { + #confirmationMessage { display: inline-block; width: 18px; height: 13px; @@ -24,7 +29,8 @@ display: none; } - #popupButton { margin-left: 10px; } + #popupRemove { margin: 5px; } + #popupExclude { margin: 5px; } #popupMenu ul { list-style: none; @@ -52,9 +58,11 @@ <body> <div id="vimiumPopup"> <div id="excludeControls"> - <input id="popupInput" type="text" /> - <input id="popupButton" type="button" value="Exclude URL" /> - <span id="excludeConfirm">Saved exclude pattern.</span> + <input id="popupPattern" placeholder="Pattern against which to match URLs..." type="text" /><br/> + <input id="popupPassKeys" placeholder="Only exclude these keys..." type="text" /><br/> + <input id="popupRemove" type="button" value="Remove Rule" /> + <input id="popupExclude" type="button" value="Add or Update Rule" /> + <span id="confirmationMessage">Text is added in popup.coffee.</span> </div> <div id="popupMenu"> diff --git a/tests/unit_tests/exclusion_test.coffee b/tests/unit_tests/exclusion_test.coffee new file mode 100644 index 00000000..a24c3b67 --- /dev/null +++ b/tests/unit_tests/exclusion_test.coffee @@ -0,0 +1,73 @@ + +require "./test_helper.js" +require "./test_chrome_stubs.js" + +# FIXME: +# Would like to do: +# extend(global, require "../../background_scripts/marks.js") +# But it looks like marks.coffee has never been included in a test before! +# Temporary fix... +root.Marks = + create: () -> true + goto: + bind: () -> true + +extend(global, require "../../lib/utils.js") +Utils.getCurrentVersion = -> '1.44' +extend(global,require "../../background_scripts/sync.js") +extend(global,require "../../background_scripts/settings.js") +Sync.init() +extend(global, require "../../background_scripts/exclusions.js") +extend(global, require "../../background_scripts/commands.js") +extend(global, require "../../background_scripts/main.js") + +# These tests cover only the most basic aspects of excluded URLs and passKeys. +# +context "Excluded URLs and pass keys", + + # These tests have no setup, they use the default values from settings.coffee. + + should "be disabled for excluded sites", -> + rule = isEnabledForUrl({ url: 'http://www.google.com/calendar/page' }) + assert.isFalse rule.isEnableForUrl + assert.isFalse rule.passKeys + + should "be enabled, but with pass keys", -> + rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' }) + assert.isTrue rule.isEnabledForUrl + assert.isFalse rule.passKeys + addExclusionRule("http*://www.facebook.com/*","oO") + rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' }) + assert.isTrue rule.isEnabledForUrl + assert.equal rule.passKeys, 'oO' + + should "be enabled", -> + rule = isEnabledForUrl({ url: 'http://www.twitter.com/pages' }) + assert.isTrue rule.isEnabledForUrl + assert.isFalse rule.passKeys + + should "add a new excluded URL", -> + rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) + assert.isTrue rule.isEnabledForUrl + addExclusionRule("http://www.example.com*") + rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) + assert.isFalse rule.isEnabledForUrl + assert.isFalse rule.passKeys + + should "add a new excluded URL with passkeys", -> + rule = isEnabledForUrl({ url: 'http://www.anotherexample.com/page' }) + assert.isTrue rule.isEnabledForUrl + addExclusionRule("http://www.anotherexample.com/*","jk") + rule = isEnabledForUrl({ url: 'http://www.anotherexample.com/page' }) + assert.isTrue rule.isEnabledForUrl + assert.equal rule.passKeys, 'jk' + + should "update an existing excluded URL with passkeys", -> + rule = isEnabledForUrl({ url: 'http://mail.google.com/page' }) + assert.isFalse rule.isEnabledForUrl + assert.isFalse rule.passKeys + addExclusionRule("http*://mail.google.com/*","jknp") + rule = isEnabledForUrl({ url: 'http://mail.google.com/page' }) + assert.isTrue rule.isEnabledForUrl + assert.equal rule.passKeys, 'jknp' + diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index e9c48f31..9622f85f 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -2,26 +2,55 @@ # # This is a stub for chrome.strorage.sync for testing. # It does what chrome.storage.sync should do (roughly), but does so synchronously. +# It also provides stubs for a number of other chrome APIs. # +global.window = {} +global.localStorage = {} + global.chrome = - runtime: {} + runtime: + getManifest: () -> + version: "1.2.3" + onConnect: + addListener: () -> true + onMessage: + addListener: () -> true - storage: + tabs: + onSelectionChanged: + addListener: () -> true + onUpdated: + addListener: () -> true + onAttached: + addListener: () -> true + onMoved: + addListener: () -> true + onRemoved: + addListener: () -> true + onActiveChanged: + addListener: () -> true + query: () -> true + windows: + onRemoved: + addListener: () -> true + getAll: () -> true + + storage: # chrome.storage.onChanged onChanged: addListener: (func) -> @func = func # Fake a callback from chrome.storage.sync. call: (key, value) -> - chrome.runtime = { lastError: undefined } + chrome.runtime.lastError = undefined key_value = {} key_value[key] = { newValue: value } @func(key_value,'synced storage stub') if @func callEmpty: (key) -> - chrome.runtime = { lastError: undefined } + chrome.runtime.lastError = undefined if @func items = {} items[key] = {} @@ -32,7 +61,7 @@ global.chrome = store: {} set: (items, callback) -> - chrome.runtime = { lastError: undefined } + chrome.runtime.lastError = undefined for own key, value of items @store[key] = value callback() if callback @@ -41,7 +70,7 @@ global.chrome = global.chrome.storage.onChanged.call(key,value) get: (keys, callback) -> - chrome.runtime = { lastError: undefined } + chrome.runtime.lastError = undefined if keys == null keys = [] for own key, value of @store @@ -53,7 +82,7 @@ global.chrome = callback items if callback remove: (key, callback) -> - chrome. runtime = { lastError: undefined } + chrome.runtime.lastError = undefined if key of @store delete @store[key] callback() if callback |
