diff options
27 files changed, 824 insertions, 367 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9382a020..a417caf5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,34 @@ reports: When you're done with your changes, send us a pull request on Github. Feel free to include a change to the CREDITS file with your patch. +Vimium design goals +------------------- + +When improving Vimium it's helpful to know what design goals we're optimizing for. + +The core goal is to make it easy to navigate the web using just the keyboard. When people first start using +Vimium, it provides an incredibly powerful workflow improvement and it makes them feel awesome. And it turns +out that Vimium is applicable to a huge, broad population of people, not just users of Vim, which is great. + +A secondary goal is to make Vimium approachable, or in other words, to minimize the barriers which will +prevent a new user from feeling awesome. Many of Vimium's users haven't used Vim before (about 1 in 5 app +store reviews say this), and most people have strong web browsing habits forged from years of browsing that +they rely on. Given that, it's a great experience when Vimium feels like a natural addition to Chrome which +augments but doesn't break their current browsing habits. + +In some ways, making software approachable is even harder than just enabling the core use case. But in this +area, Vimium really shines. It's approachable today because: + +1. It's simple to understand (even if you're not very familiar with Vim). The Vimium video shows you all you + need to know to start using Vimium and feel awesome. +2. The core feature set works in almost all cases on all sites, so Vimium feels reliable. +3. Requires no configuration or doc-reading before it's useful. Just watch the video or hit `?`. +4. Doesn't drastically change the way Chrome looks or behaves. You can transition into using Vimium piecemeal; + you don't need to jump in whole-hog from the start. +5. The core feature set isn't overwhelming. This is easy to degrade as we evolve Vimium, so it requires active + effort to maintain this feel. +6. Developers find the code is relatively simple and easy to jump into, so we have an active dev community. + ## What makes for a good feature request/contribution to Vimium? Good features: @@ -96,23 +124,3 @@ We use these guidelines, in addition to the code complexity, when deciding wheth If you're worried that a feature you plan to build won't be a good fit for core Vimium, just open a github issue for discussion or send an email to the Vimium mailing list. - -## How to release Vimium to the Chrome Store - -This process is currently only done by Phil or Ilya. - -1. Increment the version number in manifest.json -2. Update the Changelog in README.md - - You can see a summary of commits since the last version: `git log --oneline v1.45..` - -3. Push your commits -4. Create a git tag for this newly released version - - git tag -a v1.45 -m "v1.45 release" - -5. Run `cake package` -6. Take the distributable found in `dist` and upload it - [here](https://chrome.google.com/webstore/developer/dashboard) -7. Update the description in the Chrome store to include the latest version's release notes -8. Celebrate @@ -3,8 +3,8 @@ Vimium - The Hacker's Browser [](https://travis-ci.org/philc/vimium) -Vimium is a Chrome extension that provides keyboard-based navigation and control in the spirit of the Vim -editor. +Vimium is a Chrome extension that provides keyboard-based navigation and control of the web in the spirit of +the Vim editor. __Installation instructions:__ @@ -15,14 +15,16 @@ Please see [CONTRIBUTING.md](https://github.com/philc/vimium/blob/master/CONTRIBUTING.md#installing-from-source) for instructions on how you can install Vimium from source. -The Options page can be reached via a link on the help dialog (hit `?`) or via the button next to Vimium on +The Options page can be reached via a link on the help dialog (type `?`) or via the button next to Vimium on the Chrome Extensions page (`chrome://extensions`). Keyboard Bindings ----------------- Modifier keys are specified as `<c-x>`, `<m-x>`, and `<a-x>` for ctrl+x, meta+x, and alt+x -respectively. See the next section for instructions on customizing these bindings. +respectively. See the next section for how to customize these bindings. + +Once you have Vimium installed, you can see this list of key bindings at any time by typing `?`. Navigating the current page: @@ -90,7 +92,7 @@ Additional advanced browsing commands: Vimium supports command repetition so, for example, hitting '5t' will open 5 tabs in rapid succession. `<ESC>` (or `<c-[>`) will clear any partial commands in the queue and will also exit insert and find modes. -There are some advanced commands which aren't documented here. Refer to the help dialog (type `?`) for a full +There are some advanced commands which aren't documented here; refer to the help dialog (type `?`) for a full list. Custom Key Mappings diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index 10fa323b..585ef572 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -207,8 +207,6 @@ defaultKeyMappings = "F": "LinkHints.activateModeToOpenInNewTab" "<a-f>": "LinkHints.activateModeWithQueue" - "af": "LinkHints.activateModeToDownloadLink" - "/": "enterFindMode" "n": "performFind" "N": "performBackwardsFind" diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index 23696185..dc5519d5 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -26,7 +26,6 @@ class Suggestion generateHtml: -> return @html if @html - favIconUrl = @tabFavIconUrl or "#{@getUrlRoot(@url)}/favicon.ico" relevancyHtml = if @showRelevancy then "<span class='relevancy'>#{@computeRelevancy()}</span>" else "" # NOTE(philc): We're using these vimium-specific class names so we don't collide with the page's CSS. @html = @@ -35,8 +34,7 @@ class Suggestion <span class="vimiumReset vomnibarSource">#{@type}</span> <span class="vimiumReset vomnibarTitle">#{@highlightTerms(Utils.escapeHtml(@title))}</span> </div> - <div class="vimiumReset vomnibarBottomHalf vomnibarIcon" - style="background-image: url(#{favIconUrl});"> + <div class="vimiumReset vomnibarBottomHalf"> <span class="vimiumReset vomnibarUrl">#{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))}</span> #{relevancyHtml} </div> @@ -238,7 +236,7 @@ class DomainCompleter onComplete() onPageVisited: (newPage) -> - domain = @parseDomain(newPage.url) + domain = @parseDomainAndScheme newPage.url if domain slot = @domains[domain] ||= { entry: newPage, referenceCount: 0 } # We want each entry in our domains hash to point to the most recent History entry for that domain. @@ -250,15 +248,58 @@ class DomainCompleter @domains = {} else toRemove.urls.forEach (url) => - domain = @parseDomain(url) + domain = @parseDomainAndScheme url if domain and @domains[domain] and ( @domains[domain].referenceCount -= 1 ) == 0 delete @domains[domain] - parseDomain: (url) -> url.split("/")[2] || "" + # Return something like "http://www.example.com" or false. + parseDomainAndScheme: (url) -> + Utils.hasFullUrlPrefix(url) and not Utils.hasChromePrefix(url) and url.split("/",3).join "/" # Suggestions from the Domain completer have the maximum relevancy. They should be shown first in the list. computeRelevancy: -> 1 +# TabRecency associates a logical timestamp with each tab id. These are used to provide an initial +# recency-based ordering in the tabs vomnibar (which allows jumping quickly between recently-visited tabs). +class TabRecency + timestamp: 1 + current: -1 + cache: {} + lastVisited: null + lastVisitedTime: null + timeDelta: 500 # Milliseconds. + + constructor: -> + chrome.tabs.onActivated.addListener (activeInfo) => @register activeInfo.tabId + chrome.tabs.onRemoved.addListener (tabId) => @deregister tabId + + chrome.tabs.onReplaced.addListener (addedTabId, removedTabId) => + @deregister removedTabId + @register addedTabId + + register: (tabId) -> + currentTime = new Date() + # Register tabId if it has been visited for at least @timeDelta ms. Tabs which are visited only for a + # very-short time (e.g. those passed through with `5J`) aren't registered as visited at all. + if @lastVisitedTime? and @timeDelta <= currentTime - @lastVisitedTime + @cache[@lastVisited] = ++@timestamp + + @current = @lastVisited = tabId + @lastVisitedTime = currentTime + + deregister: (tabId) -> + if tabId == @lastVisited + # Ensure we don't register this tab, since it's going away. + @lastVisited = @lastVisitedTime = null + delete @cache[tabId] + + # Recently-visited tabs get a higher score (except the current tab, which gets a low score). + recencyScore: (tabId) -> + @cache[tabId] ||= 1 + if tabId == @current then 0.0 else @cache[tabId] / @timestamp + +tabRecency = new TabRecency() + # Searches through all open tabs, matching on title and URL. class TabCompleter filter: (queryTerms, onComplete) -> @@ -269,12 +310,14 @@ class TabCompleter suggestions = results.map (tab) => suggestion = new Suggestion(queryTerms, "tab", tab.url, tab.title, @computeRelevancy) suggestion.tabId = tab.id - suggestion.tabFavIconUrl = tab.favIconUrl suggestion onComplete(suggestions) computeRelevancy: (suggestion) -> - RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title) + if suggestion.queryTerms.length + RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title) + else + tabRecency.recencyScore(suggestion.tabId) # A completer which will return your search engines class SearchEngineCompleter @@ -547,3 +590,4 @@ root.SearchEngineCompleter = SearchEngineCompleter root.HistoryCache = HistoryCache root.RankingUtils = RankingUtils root.RegexpCache = RegexpCache +root.TabRecency = TabRecency diff --git a/background_scripts/exclusions.coffee b/background_scripts/exclusions.coffee index 3a8ef1e7..2b34238b 100644 --- a/background_scripts/exclusions.coffee +++ b/background_scripts/exclusions.coffee @@ -6,7 +6,12 @@ RegexpCache = if regexp = @cache[pattern] regexp else - @cache[pattern] = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$") + @cache[pattern] = + # We use try/catch to ensure that a broken regexp doesn't wholly cripple Vimium. + try + new RegExp("^" + pattern.replace(/\*/g, ".*") + "$") + catch + /^$/ # Match the empty string. # The Exclusions class manages the exclusion rule setting. # An exclusion is an object with two attributes: pattern and passKeys. diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index 0f2c6d85..17d21bc4 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -8,7 +8,7 @@ keyQueue = "" # Queue of keys typed validFirstKeys = {} singleKeyCommands = [] focusedFrame = null -framesForTab = {} +frameIdsForTab = {} # Keys are either literal characters, or "named" - for example <a-b> (alt+b), <left> (left arrow) or <f12> # This regular expression captures two groups: the first is a named key, the second is the remainder of @@ -231,7 +231,7 @@ moveTab = (callback, direction) -> # These are commands which are bound to keystroke which must be handled by the background page. They are # mapped in commands.coffee. BackgroundCommands = - createTab: (callback) -> chrome.tabs.create({ url: "chrome://newtab" }, (tab) -> callback()) + createTab: (callback) -> chrome.tabs.create({url: Settings.get("newTabUrl")}, (tab) -> callback()) duplicateTab: (callback) -> chrome.tabs.getSelected(null, (tab) -> chrome.tabs.duplicate(tab.id) @@ -282,16 +282,14 @@ BackgroundCommands = { name: "toggleHelpDialog", dialogHtml: helpDialogHtml(), frameId:frameId })) moveTabLeft: (count) -> moveTab(null, -count) moveTabRight: (count) -> moveTab(null, count) - nextFrame: (count) -> + nextFrame: (count,frameId) -> chrome.tabs.getSelected(null, (tab) -> - frames = framesForTab[tab.id].frames - currIndex = getCurrFrameIndex(frames) - - # TODO: Skip the "top" frame (which doesn't actually have a <frame> tag), - # since it exists only to contain the other frames. - newIndex = (currIndex + count) % frames.length - - chrome.tabs.sendMessage(tab.id, { name: "focusFrame", frameId: frames[newIndex].id, highlight: true })) + frames = frameIdsForTab[tab.id] + # We can't always track which frame chrome has focussed, but here we learn that it's frameId; so add an + # additional offset such that we do indeed start from frameId. + count = (count + Math.max 0, frameIdsForTab[tab.id].indexOf frameId) % frames.length + frames = frameIdsForTab[tab.id] = [frames[count..]..., frames[0...count]...] + chrome.tabs.sendMessage(tab.id, { name: "focusFrame", frameId: frames[0], highlight: true })) closeTabsOnLeft: -> removeTabsRelative "before" closeTabsOnRight: -> removeTabsRelative "after" @@ -347,7 +345,7 @@ updateOpenTabs = (tab) -> scrollY: null deletor: null # Frames are recreated on refresh - delete framesForTab[tab.id] + delete frameIdsForTab[tab.id] setBrowserActionIcon = (tabId,path) -> chrome.browserAction.setIcon({ tabId: tabId, path: path }) @@ -393,10 +391,9 @@ chrome.tabs.onUpdated.addListener (tabId, changeInfo, tab) -> allFrames: true code: Settings.get("userDefinedLinkHintCss") runAt: "document_start" - chrome.tabs.insertCSS tabId, cssConf, -> - if not chrome.runtime.lastError - updateOpenTabs(tab) - updateActiveState(tabId) + chrome.tabs.insertCSS tabId, cssConf, -> chrome.runtime.lastError + updateOpenTabs(tab) if changeInfo.url? + updateActiveState(tabId) chrome.tabs.onAttached.addListener (tabId, attachedInfo) -> # We should update all the tabs in the old window and the new window. @@ -430,7 +427,7 @@ chrome.tabs.onRemoved.addListener (tabId) -> # scroll position) tabInfoMap.deletor = -> delete tabInfoMap[tabId] setTimeout tabInfoMap.deletor, 1000 - delete framesForTab[tabId] + delete frameIdsForTab[tabId] chrome.tabs.onActiveChanged.addListener (tabId, selectInfo) -> updateActiveState(tabId) @@ -555,9 +552,9 @@ checkKeyQueue = (keysToCheck, tabId, frameId) -> refreshedCompletionKeys = true else if registryEntry.passCountToFunction - BackgroundCommands[registryEntry.command](count) + BackgroundCommands[registryEntry.command](count, frameId) else if registryEntry.noRepeat - BackgroundCommands[registryEntry.command]() + BackgroundCommands[registryEntry.command](frameId) else repeatFunction(BackgroundCommands[registryEntry.command], count, 0, frameId) @@ -604,21 +601,21 @@ openOptionsPageInNewTab = -> chrome.tabs.create({ url: chrome.runtime.getURL("pages/options.html"), index: tab.index + 1 })) registerFrame = (request, sender) -> - unless framesForTab[sender.tab.id] - framesForTab[sender.tab.id] = { frames: [] } + (frameIdsForTab[sender.tab.id] ?= []).push request.frameId - if (request.is_top) - focusedFrame = request.frameId - framesForTab[sender.tab.id].total = request.total - - framesForTab[sender.tab.id].frames.push({ id: request.frameId }) - -handleFrameFocused = (request, sender) -> focusedFrame = request.frameId +unregisterFrame = (request, sender) -> + tabId = sender.tab.id + if frameIdsForTab[tabId]? + if request.tab_is_closing + updateOpenTabs sender.tab + else + frameIdsForTab[tabId] = frameIdsForTab[tabId].filter (id) -> id != request.frameId -getCurrFrameIndex = (frames) -> - for i in [0...frames.length] - return i if frames[i].id == focusedFrame - frames.length + 1 +handleFrameFocused = (request, sender) -> + tabId = sender.tab.id + if frameIdsForTab[tabId]? + frameIdsForTab[tabId] = + [request.frameId, (frameIdsForTab[tabId].filter (id) -> id != request.frameId)...] # Send message back to the tab unchanged. This allows different frames from the same tab to message eachother # while avoiding security concerns such as eavesdropping or message spoofing. @@ -640,6 +637,7 @@ sendRequestHandlers = openUrlInCurrentTab: openUrlInCurrentTab openOptionsPageInNewTab: openOptionsPageInNewTab registerFrame: registerFrame + unregisterFrame: unregisterFrame frameFocused: handleFrameFocused upgradeNotificationClosed: upgradeNotificationClosed updateScrollPosition: handleUpdateScrollPosition diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index 7150fcba..4a63a5fb 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -61,6 +61,7 @@ root.Settings = Settings = # or strings defaults: scrollStepSize: 60 + smoothScroll: true keyMappings: "# Insert your prefered key mappings here." linkHintCharacters: "sadfjklewcmpgh" linkHintNumbers: "0123456789" @@ -105,6 +106,7 @@ root.Settings = Settings = searchUrl: "http://www.google.com/search?q=" # put in an example search engine searchEngines: "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s" + newTabUrl: "chrome://newtab" settingsVersion: Utils.getCurrentVersion() diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index f3c632b3..5eb1c5e2 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -1,93 +1,217 @@ -window.Scroller = root = {} - # # activatedElement is different from document.activeElement -- the latter seems to be reserved mostly for # input elements. This mechanism allows us to decide whether to scroll a div or to scroll the whole document. # activatedElement = null -root.init = -> - handlerStack.push DOMActivate: -> activatedElement = event.target - scrollProperties = x: { axisName: 'scrollLeft' max: 'scrollWidth' - viewSize: 'clientHeight' + viewSize: 'clientWidth' } y: { axisName: 'scrollTop' max: 'scrollHeight' - viewSize: 'clientWidth' + viewSize: 'clientHeight' } -getDimension = (el, direction, name) -> - # the clientSizes of the body are the dimensions of the entire page, but the viewport should only be the - # part visible through the window - if name is 'viewSize' and el is document.body - if direction is 'x' then window.innerWidth else window.innerHeight +# Translate a scroll request into a number (which will be interpreted by `scrollBy` as a relative amount, or +# by `scrollTo` as an absolute amount). :direction must be "x" or "y". :amount may be either a number (in +# which case it is simply returned) or a string. If :amount is a string, then it is either "max" (meaning the +# height or width of element), or "viewSize". In both cases, we look up and return the requested amount, +# either in `element` or in `window`, as appropriate. +getDimension = (el, direction, amount) -> + if Utils.isString amount + name = amount + # the clientSizes of the body are the dimensions of the entire page, but the viewport should only be the + # part visible through the window + if name is 'viewSize' and el is document.body + # TODO(smblott) Should we not be returning the width/height of element, here? + if direction is 'x' then window.innerWidth else window.innerHeight + else + el[scrollProperties[direction][name]] else - el[scrollProperties[direction][name]] + amount -# Chrome does not report scrollHeight accurately for nodes with pseudo-elements of height 0 (bug 110149). -# Therefore we cannot figure out if we have scrolled to the bottom of an element by testing if scrollTop + -# clientHeight == scrollHeight. So just try to increase scrollTop blindly -- if it fails we know we have -# reached the end of the content. -ensureScrollChange = (direction, changeFn) -> +# Perform a scroll. Return true if we successfully scrolled by the requested amount, and false otherwise. +performScroll = (element, direction, amount) -> axisName = scrollProperties[direction].axisName - element = activatedElement - loop - oldScrollValue = element[axisName] - changeFn(element, axisName) - break unless (element[axisName] == oldScrollValue && element != document.body) - lastElement = element - # we may have an orphaned element. if so, just scroll the body element. - element = element.parentElement || document.body - - # if the activated element has been scrolled completely offscreen, subsequent changes in its scroll - # position will not provide any more visual feedback to the user. therefore we deactivate it so that - # subsequent scrolls only move the parent element. + before = element[axisName] + element[axisName] += amount + element[axisName] == amount + before + +# Test whether `element` should be scrolled. E.g. hidden elements should not be scrolled. +shouldScroll = (element, direction) -> + computedStyle = window.getComputedStyle(element) + # Elements with `overflow: hidden` must not be scrolled. + return false if computedStyle.getPropertyValue("overflow-#{direction}") == "hidden" + # Elements which are not visible should not be scrolled. + return false if computedStyle.getPropertyValue("visibility") in ["hidden", "collapse"] + return false if computedStyle.getPropertyValue("display") == "none" + true + +# Test whether element does actually scroll in the direction required when asked to do so. Due to chrome bug +# 110149, scrollHeight and clientHeight cannot be used to reliably determine whether an element will scroll. +# Instead, we scroll the element by 1 or -1 and see if it moved (then put it back). :factor is the factor by +# which :scrollBy and :scrollTo will later scale the scroll amount. :factor can be negative, so we need it +# here in order to decide whether we should test a forward scroll or a backward scroll. +# Bug verified in Chrome 38.0.2125.104. +doesScroll = (element, direction, amount, factor) -> + # amount is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls (only + # gg, G, and friends), amount can be either a string ("max" or "viewSize") or zero. In the former case, + # we're definitely scrolling forwards, so any positive value will do for delta. In the latter, we're + # definitely scrolling backwards, so a delta of -1 will do. For absolute scrolls, factor is always 1. + delta = factor * getDimension(element, direction, amount) || -1 + delta = Math.sign delta # 1 or -1 + performScroll(element, direction, delta) and performScroll(element, direction, -delta) + +# From element and its parents, find the first which we should scroll and which does scroll. +findScrollableElement = (element, direction, amount, factor) -> + while element != document.body and + not (doesScroll(element, direction, amount, factor) and shouldScroll(element, direction)) + element = element.parentElement || document.body + element + +checkVisibility = (element) -> + # If the activated element has been scrolled completely offscreen, then subsequent changes in its scroll + # position will not provide any more visual feedback to the user. Therefore, we deactivate it so that + # subsequent scrolls affect the parent element. rect = activatedElement.getBoundingClientRect() if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) activatedElement = element -# scroll the active element in :direction by :amount * :factor. -# :factor is needed because :amount can take on string values, which scrollBy converts to element dimensions. -root.scrollBy = (direction, amount, factor = 1) -> - # if this is called before domReady, just use the window scroll function - if (!document.body and amount instanceof Number) - if (direction == "x") - window.scrollBy(amount, 0) - else - window.scrollBy(0, amount) - return +# How scrolling is handled by CoreScroller. +# - For jump scrolling, the entire scroll happens immediately. +# - For smooth scrolling with distinct key presses, a separate animator is initiated for each key press. +# Therefore, several animators may be active at the same time. This ensures that two quick taps on `j` +# scroll to the same position as two slower taps. +# - For smooth scrolling with keyboard repeat (continuous scrolling), the most recently-activated animator +# continues scrolling at least until its keyup event is received. We never initiate a new animator on +# keyboard repeat. - if (!activatedElement || !isRendered(activatedElement)) - activatedElement = document.body +# CoreScroller contains the core function (scroll) and logic for relative scrolls. All scrolls are ultimately +# translated to relative scrolls. CoreScroller is not exported. +CoreScroller = + init: (frontendSettings) -> + @settings = frontendSettings + @time = 0 + @lastEvent = null + @keyIsDown = false - ensureScrollChange direction, (element, axisName) -> - if Utils.isString amount - elementAmount = getDimension element, direction, amount - else - elementAmount = amount - elementAmount *= factor - element[axisName] += elementAmount + handlerStack.push + keydown: (event) => + @keyIsDown = true + @lastEvent = event + keyup: => + @keyIsDown = false + @time += 1 -root.scrollTo = (direction, pos) -> - return unless document.body + # Return true if CoreScroller would not initiate a new scroll right now. + wouldNotInitiateScroll: -> @lastEvent?.repeat and @settings.get "smoothScroll" - if (!activatedElement || !isRendered(activatedElement)) - activatedElement = document.body + # Calibration fudge factors for continuous scrolling. The calibration value starts at 1.0. We then + # increase it (until it exceeds @maxCalibration) if we guess that the scroll is too slow, or decrease it + # (until it is less than @minCalibration) if we guess that the scroll is too fast. The cutoff point for + # which guess we make is @calibrationBoundary. We require: 0 < @minCalibration <= 1 <= @maxCalibration. + minCalibration: 0.5 # Controls how much we're willing to slow scrolls down; smaller means more slow down. + maxCalibration: 1.6 # Controls how much we're willing to speed scrolls up; bigger means more speed up. + calibrationBoundary: 150 # Boundary between scrolls which are considered too slow, or too fast. - ensureScrollChange direction, (element, axisName) -> - if Utils.isString pos - elementPos = getDimension element, direction, pos - else - elementPos = pos - element[axisName] = elementPos - -# TODO refactor and put this together with the code in getVisibleClientRect -isRendered = (element) -> - computedStyle = window.getComputedStyle(element, null) - return !(computedStyle.getPropertyValue("visibility") != "visible" || - computedStyle.getPropertyValue("display") == "none") + # Scroll element by a relative amount (a number) in some direction. + scroll: (element, direction, amount) -> + return unless amount + + unless @settings.get "smoothScroll" + # Jump scrolling. + performScroll element, direction, amount + checkVisibility element + return + + # We don't activate new animators on keyboard repeats; rather, the most-recently activated animator + # continues scrolling. + return if @lastEvent?.repeat + + activationTime = ++@time + myKeyIsStillDown = => @time == activationTime and @keyIsDown + + # Store amount's sign and make amount positive; the arithmetic is clearer when amount is positive. + sign = Math.sign amount + amount = Math.abs amount + + # Initial intended scroll duration (in ms). We allow a bit longer for longer scrolls. + duration = Math.max 100, 20 * Math.log amount + + totalDelta = 0 + totalElapsed = 0.0 + calibration = 1.0 + previousTimestamp = null + + animate = (timestamp) => + previousTimestamp ?= timestamp + return requestAnimationFrame(animate) if timestamp == previousTimestamp + + # The elapsed time is typically about 16ms. + elapsed = timestamp - previousTimestamp + totalElapsed += elapsed + previousTimestamp = timestamp + + # The constants in the duration calculation, above, are chosen to provide reasonable scroll speeds for + # distinct keypresses. For continuous scrolls, some scrolls are too slow, and others too fast. Here, we + # speed up the slower scrolls, and slow down the faster scrolls. + if myKeyIsStillDown() and 75 <= totalElapsed and @minCalibration <= calibration <= @maxCalibration + calibration *= 1.05 if 1.05 * calibration * amount < @calibrationBoundary # Speed up slow scrolls. + calibration *= 0.95 if @calibrationBoundary < 0.95 * calibration * amount # Slow down fast scrolls. + + # Calculate the initial delta, rounding up to ensure progress. Then, adjust delta to account for the + # current scroll state. + delta = Math.ceil amount * (elapsed / duration) * calibration + delta = if myKeyIsStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta + + if delta and performScroll element, direction, sign * delta + totalDelta += delta + requestAnimationFrame animate + else + # We're done. + checkVisibility element + + # Launch animator. + requestAnimationFrame animate + +# Scroller contains the two main scroll functions (scrollBy and scrollTo) which are exported to clients. +Scroller = + init: (frontendSettings) -> + handlerStack.push DOMActivate: -> activatedElement = event.target + CoreScroller.init frontendSettings + + # scroll the active element in :direction by :amount * :factor. + # :factor is needed because :amount can take on string values, which scrollBy converts to element dimensions. + scrollBy: (direction, amount, factor = 1) -> + # if this is called before domReady, just use the window scroll function + if (!document.body and amount instanceof Number) + if (direction == "x") + window.scrollBy(amount, 0) + else + window.scrollBy(0, amount) + return + + activatedElement ||= document.body + return unless activatedElement + + # Avoid the expensive scroll calculation if it will not be used. This reduces costs during smooth, + # continuous scrolls, and is just an optimization. + unless CoreScroller.wouldNotInitiateScroll() + element = findScrollableElement activatedElement, direction, amount, factor + elementAmount = factor * getDimension element, direction, amount + CoreScroller.scroll element, direction, elementAmount + + scrollTo: (direction, pos) -> + return unless document.body or activatedElement + activatedElement ||= document.body + + element = findScrollableElement activatedElement, direction, pos, 1 + amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] + CoreScroller.scroll element, direction, amount + +root = exports ? window +root.Scroller = Scroller diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css index fd613b31..a042a4cf 100644 --- a/content_scripts/vimium.css +++ b/content_scripts/vimium.css @@ -2,6 +2,10 @@ * Many CSS class names in this file use the verbose "vimiumXYZ" as the class name. This is so we don't * use the same CSS class names that the page is using, so the page's CSS doesn't mess with the style of our * Vimium dialogs. + * + * The z-indexes of Vimium elements are very large, because we always want them to show on top. Chrome may + * support up to Number.MAX_VALUE, which is approximately 1.7976e+308. We're using 2**31, which is the max value of a singed 32 bit int. + * Let's use try larger valeus if 2**31 empirically isn't large enough. */ /* @@ -54,7 +58,7 @@ tr.vimiumReset { vertical-align: baseline; white-space: normal; width: auto; - z-index: 99999999; + z-index: 2147483648; } /* Linkhints CSS */ @@ -122,7 +126,8 @@ div#vimiumHelpDialog { top:50px; -webkit-box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 6px; overflow-y: auto; - z-index:99999998; + /* One less than vimiumReset */ + z-index: 2147483647; } div#vimiumHelpDialog a { color:blue; } @@ -233,8 +238,8 @@ div.vimiumHUD { border-radius: 4px 4px 0 0; font-family: "Lucida Grande", "Arial", "Sans"; font-size: 12px; - /* One less than vimium's hint markers, so link hints can be shown e.g. for the panel's close button. */ - z-index: 99999997; + /* One less than vimium's hint markers, so link hints can be shown e.g. for the HUD panel's close button. */ + z-index: 2147483646; text-shadow: 0px 1px 2px #FFF; line-height: 1.0; opacity: 0; @@ -287,7 +292,7 @@ iframe.vomnibarFrame { font-family: sans-serif; /* One less than hint markers and the help dialog. */ - z-index: 99999996; + z-index: 2147483645; } div#vimiumFlash { @@ -295,5 +300,5 @@ div#vimiumFlash { padding: 1px; background-color: transparent; position: absolute; - z-index: 99999; + z-index: 2147483648; } diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 81427c1a..b4f0264c 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -49,7 +49,7 @@ settings = loadedValues: 0 valuesToLoad: ["scrollStepSize", "linkHintCharacters", "linkHintNumbers", "filterLinkHints", "hideHud", "previousPatterns", "nextPatterns", "findModeRawQuery", "regexFindMode", "userDefinedLinkHintCss", - "helpDialog_showAdvancedCommands"] + "helpDialog_showAdvancedCommands", "smoothScroll"] isLoaded: false eventListeners: {} @@ -101,7 +101,7 @@ initializePreDomReady = -> settings.addEventListener("load", LinkHints.init.bind(LinkHints)) settings.load() - Scroller.init() + Scroller.init settings checkIfEnabledForUrl() @@ -138,31 +138,36 @@ initializePreDomReady = -> false # Wrapper to install event listeners. Syntactic sugar. -installListener = (event, callback) -> document.addEventListener(event, callback, true) +installListener = (element, event, callback) -> + element.addEventListener(event, -> + if isEnabledForUrl then callback.apply(this, arguments) else true + , true) # -# 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. +# Installing or uninstalling listeners is error prone. Instead we elect to check isEnabledForUrl each time so +# we know whether the listener should run or not. +# Run this as early as possible, so the page can't register any event handlers before us. # 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) + # Key event handlers fire on window before they do on document. Prefer window for key events so the page + # can't set handlers to grab the keys before us. + installListener window, "keydown", onKeydown + installListener window, "keypress", onKeypress + installListener window, "keyup", onKeyup + installListener document, "focus", onFocusCapturePhase + installListener document, "blur", onBlurCapturePhase + installListener document, "DOMActivate", onDOMActivate enterInsertModeIfElementIsFocused() installedListeners = true setState = (request) -> + initializeWhenEnabled(request.passKeys) if request.enabled isEnabledForUrl = request.enabled passKeys = request.passKeys - initializeWhenEnabled(passKeys) if isEnabledForUrl and !installedListeners # # The backend needs to know which frame has focus. @@ -176,19 +181,24 @@ window.addEventListener "focus", -> # Initialization tasks that must wait for the document to be ready. # initializeOnDomReady = -> - registerFrame(window.top == window.self) - enterInsertModeIfElementIsFocused() if isEnabledForUrl # Tell the background page we're in the dom ready state. chrome.runtime.connect({ name: "domReady" }) -registerFrame = (is_top) -> - chrome.runtime.sendMessage( - handler: "registerFrame" +registerFrame = -> + # Don't register frameset containers; focusing them is no use. + if document.body.tagName != "FRAMESET" + chrome.runtime.sendMessage + handler: "registerFrame" + frameId: frameId + +# Unregister the frame if we're going to exit. +unregisterFrame = -> + chrome.runtime.sendMessage + handler: "unregisterFrame" frameId: frameId - is_top: is_top - total: frames.length + 1) + tab_is_closing: window.top == window.self # # Enters insert mode if the currently focused element in the DOM is focusable. @@ -364,7 +374,7 @@ onKeypress = (event) -> else if (!isInsertMode() && !findMode) if (isPassKey keyChar) return undefined - if (currentCompletionKeys.indexOf(keyChar) != -1) + if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar)) DomUtils.suppressEvent(event) keyPort.postMessage({ keyChar:keyChar, frameId:frameId }) @@ -427,7 +437,7 @@ onKeydown = (event) -> handledKeydownEvents.push event else if (!modifiers) - event.stopPropagation() + DomUtils.suppressPropagation(event) handledKeydownEvents.push event else if (isShowingHelpDialog && KeyboardUtils.isEscape(event)) @@ -437,7 +447,7 @@ onKeydown = (event) -> else if (!isInsertMode() && !findMode) if (keyChar) - if (currentCompletionKeys.indexOf(keyChar) != -1) + if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar)) DomUtils.suppressEvent event handledKeydownEvents.push event @@ -459,7 +469,7 @@ onKeydown = (event) -> if (keyChar == "" && !isInsertMode() && (currentCompletionKeys.indexOf(KeyboardUtils.getKeyChar(event)) != -1 || isValidFirstKey(KeyboardUtils.getKeyChar(event)))) - event.stopPropagation() + DomUtils.suppressPropagation(event) handledKeydownEvents.push event onKeyup = (event) -> @@ -475,7 +485,7 @@ onKeyup = (event) -> event.keyCode == keydown.keyCode handledKeydownEvents.splice i, 1 - event.stopPropagation() + DomUtils.suppressPropagation(event) break checkIfEnabledForUrl = -> @@ -499,7 +509,7 @@ refreshCompletionKeys = (response) -> chrome.runtime.sendMessage({ handler: "getCompletionKeys" }, refreshCompletionKeys) isValidFirstKey = (keyChar) -> - validFirstKeys[keyChar] || /[1-9]/.test(keyChar) + validFirstKeys[keyChar] || /^[1-9]/.test(keyChar) onFocusCapturePhase = (event) -> if (isFocusable(event.target) && !findMode) @@ -526,6 +536,7 @@ isEmbed = (element) -> ["embed", "object"].indexOf(element.nodeName.toLowerCase( # any element which makes it a rich text editor, like the notes on jjot.com. # isEditable = (target) -> + # Note: document.activeElement.isContentEditable is also rechecked in isInsertMode() dynamically. return true if target.isContentEditable nodeName = target.nodeName.toLowerCase() # use a blacklist instead of a whitelist because new form controls are still being implemented for html5 @@ -549,6 +560,7 @@ window.enterInsertMode = (target) -> # when the last editable element that came into focus -- which insertModeLock points to -- has been blurred. # If insert mode is entered manually (via pressing 'i'), then we set insertModeLock to 'undefined', and only # leave insert mode when the user presses <ESC>. +# Note. This returns the truthiness of target, which is required by isInsertMode. # enterInsertModeWithoutShowingIndicator = (target) -> insertModeLock = target @@ -557,7 +569,13 @@ exitInsertMode = (target) -> insertModeLock = null HUD.hide() -isInsertMode = -> insertModeLock != null +isInsertMode = -> + return true if insertModeLock != null + # Some sites (e.g. inbox.google.com) change the contentEditable attribute on the fly (see #1245); and + # unfortunately, isEditable() is called *before* the change is made. Therefore, we need to re-check whether + # the active element is contentEditable. + document.activeElement and document.activeElement.isContentEditable and + enterInsertModeWithoutShowingIndicator document.activeElement # should be called whenever rawQuery is modified. updateFindModeQuery = -> @@ -1052,6 +1070,8 @@ Tween = state.onUpdate(value) initializePreDomReady() +window.addEventListener("DOMContentLoaded", registerFrame) +window.addEventListener("unload", unregisterFrame) window.addEventListener("DOMContentLoaded", initializeOnDomReady) window.onbeforeunload = -> diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index dcdd5518..dfaa5d5f 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -32,8 +32,8 @@ DomUtils = # makeXPath: (elementArray) -> xpath = [] - for i of elementArray - xpath.push("//" + elementArray[i], "//xhtml:" + elementArray[i]) + for element in elementArray + xpath.push("//" + element, "//xhtml:" + element) xpath.join(" | ") evaluateXPath: (xpath, resultType) -> @@ -91,17 +91,23 @@ DomUtils = null # - # Selectable means the element has a text caret; this is not the same as "focusable". + # Selectable means that we should use the simulateSelect method to activate the element instead of a click. + # + # The html5 input types that should use simulateSelect are: + # ["date", "datetime", "datetime-local", "email", "month", "number", "password", "range", "search", + # "submit", "tel", "text", "time", "url", "week"] + # An unknown type will be treated the same as "text", in the same way that the browser does. # isSelectable: (element) -> - selectableTypes = ["search", "text", "password"] - (element.nodeName.toLowerCase() == "input" && selectableTypes.indexOf(element.type) >= 0) || + unselectableTypes = ["button", "checkbox", "color", "file", "hidden", "image", "radio", "reset"] + (element.nodeName.toLowerCase() == "input" && unselectableTypes.indexOf(element.type) == -1) || element.nodeName.toLowerCase() == "textarea" simulateSelect: (element) -> element.focus() # When focusing a textbox, put the selection caret at the end of the textbox's contents. - element.setSelectionRange(element.value.length, element.value.length) + # For some HTML5 input types (eg. date) we can't position the caret, so we wrap this with a try. + try element.setSelectionRange(element.value.length, element.value.length) simulateClick: (element, modifiers) -> modifiers ||= {} @@ -127,9 +133,12 @@ DomUtils = document.documentElement.appendChild(flashEl) setTimeout((-> DomUtils.removeElement flashEl), 400) + suppressPropagation: (event) -> + event.stopImmediatePropagation() + suppressEvent: (event) -> event.preventDefault() - event.stopPropagation() + @suppressPropagation(event) root = exports ? window root.DomUtils = DomUtils diff --git a/lib/utils.coffee b/lib/utils.coffee index 5d93ae70..57d8a488 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -25,27 +25,29 @@ Utils = id = 0 -> id += 1 - hasChromePrefix: (url) -> - chromePrefixes = ["about:", "view-source:", "chrome-extension:", "data:", "javascript:"] - for prefix in chromePrefixes - return true if url.startsWith prefix - false + hasChromePrefix: do -> + chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:", "javascript:" ] + (url) -> + for prefix in chromePrefixes + return true if url.startsWith prefix + false + + hasFullUrlPrefix: do -> + urlPrefix = new RegExp "^[a-z]{3,}://." + (url) -> urlPrefix.test url # Completes a partial URL (without scheme) createFullUrl: (partialUrl) -> - unless /^[a-z]{3,}:\/\//.test partialUrl - "http://" + partialUrl - else - partialUrl + if @hasFullUrlPrefix(partialUrl) then partialUrl else ("http://" + partialUrl) # Tries to detect if :str is a valid URL. isUrl: (str) -> - # Starts with a scheme: URL - return true if /^[a-z]{3,}:\/\//.test str - # Must not contain spaces return false if ' ' in str + # Starts with a scheme: URL + return true if @hasFullUrlPrefix str + # More or less RFC compliant URL host part parsing. This should be sufficient for our needs urlRegex = new RegExp( '^(?:([^:]+)(?::([^:]+))?@)?' + # user:password (optional) => \1, \2 @@ -96,7 +98,7 @@ Utils = convertToUrl: (string) -> string = string.trim() - # Special-case about:[url] and view-source:[url] + # Special-case about:[url], view-source:[url] and the like if Utils.hasChromePrefix string string else if Utils.isUrl string diff --git a/pages/blank.html b/pages/blank.html new file mode 100644 index 00000000..496c1868 --- /dev/null +++ b/pages/blank.html @@ -0,0 +1,8 @@ +<html> + <head> + <title>New Tab</title> + <script src="content_script_loader.js"></script> + </head> + <body> + </body> +</html> diff --git a/pages/content_script_loader.coffee b/pages/content_script_loader.coffee new file mode 100644 index 00000000..5058bb7b --- /dev/null +++ b/pages/content_script_loader.coffee @@ -0,0 +1,28 @@ +injectContentScripts = -> + manifest = chrome.runtime.getManifest() + content_scripts = manifest.content_scripts + + insertLocation = document.head.firstChild + + for scriptInfo in content_scripts + continue if scriptInfo.matches.indexOf("<all_urls>") == -1 + + if scriptInfo.js + for script in scriptInfo.js + scriptElement = document.createElement "script" + scriptElement.type = "text/javascript" + scriptElement.async = false # Don't load out of order! + scriptElement.src = chrome.runtime.getURL script + + insertLocation.parentElement.insertBefore scriptElement, insertLocation + + if scriptInfo.css + for style in scriptInfo.css + styleElement = document.createElement "link" + styleElement.rel = "stylesheet" + styleElement.type = "text/css" + styleElement.href = chrome.runtime.getURL style + + insertLocation.parentElement.insertBefore styleElement, insertLocation + +injectContentScripts() diff --git a/pages/options.coffee b/pages/options.coffee index 7f374f5d..cd19fa37 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -38,6 +38,12 @@ class Option bgSettings.clear @field @fetch() + # Static method. + @saveOptions: -> + Option.all.map (option) -> option.save() + $("saveOptions").disabled = true + $("saveOptions").innerHTML = "No Changes" + # Abstract method; only implemented in sub-classes. # Populate the option's DOM element (@element) with the setting's current value. # populateElement: (value) -> DO_SOMETHING @@ -51,10 +57,17 @@ class NumberOption extends Option readValueFromElement: -> parseFloat @element.value class TextOption extends Option + constructor: (field,enableSaveButton) -> + super(field,enableSaveButton) + @element.addEventListener "input", enableSaveButton populateElement: (value) -> @element.value = value readValueFromElement: -> @element.value.trim() class NonEmptyTextOption extends Option + constructor: (field,enableSaveButton) -> + super(field,enableSaveButton) + @element.addEventListener "input", enableSaveButton + 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() @@ -68,7 +81,6 @@ class ExclusionRulesOption extends Option super(args...) $("exclusionAddButton").addEventListener "click", (event) => @appendRule { pattern: "", passKeys: "" } - @maintainExclusionMargin() # Focus the pattern element in the new rule. @element.children[@element.children.length-1].children[0].children[0].focus() # Scroll the new rule into view. @@ -76,11 +88,8 @@ class ExclusionRulesOption extends Option exclusionScrollBox.scrollTop = exclusionScrollBox.scrollHeight populateElement: (rules) -> - while @element.firstChild - @element.removeChild @element.firstChild for rule in rules @appendRule rule - @maintainExclusionMargin() # Append a row for a new rule. appendRule: (rule) -> @@ -90,7 +99,7 @@ class ExclusionRulesOption extends Option for field in ["pattern", "passKeys"] element = row.querySelector ".#{field}" element.value = rule[field] - for event in [ "keyup", "change" ] + for event in [ "input", "change" ] element.addEventListener event, enableSaveButton remove = row.querySelector ".exclusionRemoveButton" @@ -98,13 +107,12 @@ class ExclusionRulesOption extends Option row = event.target.parentNode.parentNode row.parentNode.removeChild row enableSaveButton() - @maintainExclusionMargin() @element.appendChild row readValueFromElement: -> rules = - for element in @element.children + for element in @element.getElementsByClassName "exclusionRuleTemplateInstance" pattern = element.children[0].firstChild.value.trim() passKeys = element.children[1].firstChild.value.trim() { pattern: pattern, passKeys: passKeys } @@ -116,30 +124,11 @@ class ExclusionRulesOption extends Option flatten = (rule) -> if rule and rule.pattern then rule.pattern + "\n" + rule.passKeys else "" a.map(flatten).join("\n") == b.map(flatten).join("\n") - # Hack. There has to be a better way than... - # The y-axis scrollbar for "exclusionRules" is only displayed if it is needed. When visible, it appears on - # top of the enclosed content (partially obscuring it). Here, we adjust the margin of the "Remove" button to - # compensate. - maintainExclusionMargin: -> - scrollBox = $("exclusionScrollBox") - margin = if scrollBox.clientHeight < scrollBox.scrollHeight then "16px" else "0px" - for element in scrollBox.getElementsByClassName "exclusionRemoveButton" - element.style["margin-right"] = margin - # # Operations for page elements. enableSaveButton = -> $("saveOptions").removeAttribute "disabled" - -saveOptions = -> - Option.all.map (option) -> option.save() - $("saveOptions").disabled = true - -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 + $("saveOptions").innerHTML = "Save Changes" # Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints". maintainLinkHintsView = -> @@ -163,9 +152,13 @@ toggleAdvancedOptions = $("advancedOptionsLink").innerHTML = "Hide advanced options" advancedMode = !advancedMode event.preventDefault() + # Prevent the "advanced options" link from retaining the focus. + document.activeElement.blur() activateHelpDialog = -> showHelpDialog chrome.extension.getBackgroundPage().helpDialogHtml(true, true, "Command Listing"), frameId + # Prevent the "show help" link from retaining the focus. + document.activeElement.blur() # # Initialization. @@ -179,21 +172,31 @@ document.addEventListener "DOMContentLoaded", -> keyMappings: TextOption linkHintCharacters: NonEmptyTextOption linkHintNumbers: NonEmptyTextOption + newTabUrl: NonEmptyTextOption nextPatterns: NonEmptyTextOption previousPatterns: NonEmptyTextOption regexFindMode: CheckBoxOption scrollStepSize: NumberOption + smoothScroll: CheckBoxOption searchEngines: TextOption searchUrl: NonEmptyTextOption userDefinedLinkHintCss: TextOption } - $("saveOptions").addEventListener "click", saveOptions - $("restoreSettings").addEventListener "click", restoreToDefaults + $("saveOptions").addEventListener "click", Option.saveOptions $("advancedOptionsLink").addEventListener "click", toggleAdvancedOptions $("showCommands").addEventListener "click", activateHelpDialog $("filterLinkHints").addEventListener "click", maintainLinkHintsView + for element in document.getElementsByClassName "nonEmptyTextOption" + element.className = element.className + " example info" + element.innerHTML = "Leave empty to reset this option." + maintainLinkHintsView() window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled + document.addEventListener "keyup", (event) -> + if event.ctrlKey and event.keyCode == 13 + document.activeElement.blur() if document?.activeElement?.blur + Option.saveOptions() + diff --git a/pages/options.html b/pages/options.html index fb904316..b52974d6 100644 --- a/pages/options.html +++ b/pages/options.html @@ -1,25 +1,19 @@ <html> <head> <title>Vimium Options</title> - <script src="../lib/utils.js"></script> - <script src="../lib/keyboard_utils.js"></script> - <script src="../lib/dom_utils.js"></script> - <script src="../lib/handler_stack.js"></script> - <script src="../lib/clipboard.js"></script> - <script src="../content_scripts/link_hints.js"></script> - <script src="../content_scripts/vomnibar.js"></script> - <script src="../content_scripts/scroller.js"></script> - <script src="../content_scripts/vimium_frontend.js"></script> + <script src="content_script_loader.js"></script> <style type="text/css" media="screen"> body { font: 14px "DejaVu Sans", "Arial", sans-serif; color: #303942; - width: 680px; margin: 0 auto; } a, a:visited { color: #15c; } a:active { color: #052577; } - div#wrapper { width: 500px; } + div#wrapper, #footerWrapper { + width: 540px; + margin-left: 35px; + } header { font-size: 18px; font-weight: normal; @@ -93,9 +87,14 @@ color: #979ca0; margin-left: 20px; } + .info { + margin-left: 0px; + } .caption { margin-right: 10px; min-width: 130px; + padding-top: 3px; + vertical-align: top; } td { padding: 0; } div#exampleKeyMapping { @@ -103,25 +102,27 @@ margin-top: 5px; } input#linkHintCharacters { - width: 160px; + width: 100%; + } + input#linkHintNumbers { + width: 100%; + } + input#linkHintCharacters { + width: 100%; } input#scrollStepSize { width: 40px; margin-right: 3px; } - textarea#userDefinedLinkHintCss { + textarea#userDefinedLinkHintCss, textarea#keyMappings, textarea#searchEngines { width: 100%;; - min-height: 100px; - } - textarea#keyMappings { - width: 100%; - min-height: 135px; + min-height: 130px; + white-space: nowrap; } - textarea#searchEngines { + input#previousPatterns, input#nextPatterns { width: 100%; - min-height: 135px; } - input#previousPatterns, input#nextPatterns { + input#newTabUrl { width: 100%; } input#searchUrl { @@ -132,18 +133,13 @@ font-size: 80%; } /* Make the caption in the settings table as small as possible, to pull the other fields to the right. */ - td:first-child { + .caption { width: 1px; white-space: nowrap; } #buttonsPanel { width: 100%; } #advancedOptions { display: none; } #advancedOptionsLink { line-height: 24px; } - #buttonContainer { float: right; } - #buttonContainer button:last-child { - margin-right: 0; - } - #showHelpDialogMessage { width: 100%; } .help { position: absolute; right: -320px; @@ -169,37 +165,86 @@ } /* Boolean options have a tighter form representation than text options. */ td.booleanOption { font-size: 12px; } - footer { - padding: 15px 0; - border-top: 1px solid #eee; - } /* Ids and classes for rendering exclusionRules */ #exclusionScrollBox { overflow: scroll; overflow-x: hidden; overflow-y: auto; - height: 225px; - border: 1px solid #bfbfbf; + max-height: 135px; + min-height: 75px; + border-radius: 2px; + color: #444; + width: 100% + } + #exclusionScrollBox::-webkit-scrollbar { + width: 12px; + } + #exclusionScrollBox::-webkit-scrollbar-track { + -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); border-radius: 2px; + } + + #exclusionScrollBox::-webkit-scrollbar-thumb { + border-radius: 2px; + -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.5); + } + #exclusionRules { + width: 100%; + } + .exclusionRulePassKeys { + width: 33%; + } + .exclusionRemoveButton { + width: 1px; /* 1px; smaller than the button itself. */ + } + .exclusionRemoveButtonButton { + border: none; + background-color: #fff; + color: #979ca0; + } + .exclusionRemoveButtonButton:hover { color: #444; } - input.pattern, input.passKeys { + input.pattern, input.passKeys, .exclusionHeaderText { + width: 100%; font-family: Consolas, "Liberation Mono", Courier, monospace; font-size: 14px; } - .pattern { - width: 250px; - } - .passKeys { - width: 120px; + .exclusionHeaderText { + padding-left: 3px; + color: #979ca0; } #exclusionAddButton { float: right; - margin-top: 5px; margin-right: 0px; + margin-top: 5px; + } + #footer { + background: #f5f5f5; + border-top: 1px solid #979ca0; + position: fixed; + bottom: 0px; + z-index: 10; + } + #footer, #footerTable, #footerTableData { + width: 100%; + } + #endSpace { + /* Leave space for the fixed footer. */ + min-height: 30px; + max-height: 30px; + } + #helpText { + font-size: 12px; + } + #saveOptionsTableData { + float: right; + } + #saveOptions { + white-space: nowrap; + width: 110px; } </style> - <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> <script type="text/javascript" src="options.js"></script> @@ -207,73 +252,88 @@ <body> <div id="wrapper"> - <header>Vimium options</header> + <header>Vimium Options</header> <table id="options"> <tr> - <td class="caption">Scroll step size</td> - <td> - <input id="scrollStepSize" type="number" />px - </td> - </tr> - <tr> - <td>Excluded URLs<br/>and keys</td> + <td class="caption">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> + Wholly or partially disable Vimium. "Patterns" are URL regular expressions; + additionally, "*" matches any zero or more characters. + <br/><br/> + If "Keys" is left empty, then vimium is wholly disabled. + Otherwise, just the listed keys are disabled (they are passed through). </div> </div> <div> <div id="exclusionScrollBox"> - <table id="exclusionRules"></table> + <table id="exclusionRules"> + <tr> + <td><span class="exclusionHeaderText">Patterns</span></td> + <td><span class="exclusionHeaderText">Keys</span></td> + </tr> + </table> <template id="exclusionRuleTemplate"> - <tr> - <td><input/ type="text" class="pattern" placeholder="URL pattern"></td> - <td><input/ type="text" class="passKeys" placeholder="Exclude keys"></td> - <td><input/ type="button" class="exclusionRemoveButton" value="✖"></td> - </tr> + <tr class="exclusionRuleTemplateInstance"> + <td><input/ type="text" class="pattern" placeholder="URL pattern"></td> + <td class="exclusionRulePassKeys"><input/ type="text" class="passKeys" placeholder="Exclude keys"></td> + <td class="exclusionRemoveButton"> + <input/ type="button" tabindex = "-1" class="exclusionRemoveButtonButton" value="✖"></td> + </tr> </template> </div> <button id="exclusionAddButton">Add Rule</button> </div> </td> </tr> - <tbody id='advancedOptions'> - <tr> - <td class="caption">Custom key<br/>mappings</td> - <td id="mappingsHelp" verticalAlign="top"> - <div class="help"> - <div class="example"> - <!-- TODO(ilya/philc): Expand this and style it better. --> - Enter commands to remap your keys. Available commands:<br/> - <pre id="exampleKeyMapping"> + <tr> + <td class="caption">Custom key<br/>mappings</td> + <td id="mappingsHelp" verticalAlign="top"> + <div class="help"> + <div class="example"> + <!-- TODO(ilya/philc): Expand this and style it better. --> + Enter commands to remap your keys. Available commands:<br/> + <pre id="exampleKeyMapping"> map j scrollDown unmap j unmapAll " this is a comment # this is also a comment</pre> - <a href="#" id="showCommands">Show available commands.</a> - </div> + <a href="#" id="showCommands">Show available commands.</a> </div> - <textarea id="keyMappings" type="text"></textarea> - </td> - </tr> - <tr> - <td class="caption">CSS for link hints</td> - <td verticalAlign="top"> + </div> + <textarea id="keyMappings" type="text"></textarea> + </td> + </tr> + <tr> + <td class="caption">Custom search<br/>engines</td> + <td verticalAlign="top"> <div class="help"> <div class="example"> - The CSS used to style the characters next to each link hint.<br/><br/> - Note: these styles are used in addition to and take precedence over Vimium's - default styles. + This adds search-engine shortcuts to the Vomnibar.<br/><br/> + The format is:<br/> + <pre>your-keyword: http://the-site.com/?q=%s</pre> + %s will be replaced with your search terms.<br/> + Lines which start with "#" are comments. </div> </div> - <textarea id="userDefinedLinkHintCss" class="code" type="text"></textarea> + <textarea id="searchEngines"></textarea> + </td> + </tr> + <tr> + <td colspan="2"><a href="#" id="advancedOptionsLink">Show advanced options…</a></td> + </tr> + <tbody id='advancedOptions'> + <tr> + <td class="caption">Scroll step size</td> + <td> + <div class="help"> + <div class="example"> + The size for basic movements (usually j/k/h/l). + </div> + </div> + <input id="scrollStepSize" type="number" />px </td> </tr> <tr> @@ -281,21 +341,32 @@ unmapAll <td verticalAlign="top"> <div class="help"> <div class="example"> - The characters placed next to each link after typing "F" to enter link hinting mode. + The characters placed next to each link after typing "f" to enter link-hint mode. </div> </div> <input id="linkHintCharacters" type="text" /> + <div class="nonEmptyTextOption"> </td> </tr> <tr> - <td class="caption">Numbers used<br/> for filtered link hints</td> + <td class="caption">Numbers used<br/> for link hints</td> <td verticalAlign="top"> <div class="help"> <div class="example"> - The numbers placed next to each link after typing "F" to enter link hinting mode. + The numbers placed next to each link after typing "f" to enter link-hint mode. </div> </div> <input id="linkHintNumbers" type="text" /> + <div class="nonEmptyTextOption"> + </td> + </tr> + <tr> + <td class="caption" verticalAlign="top">Miscellaneous<br/>options</td> + <td verticalAlign="top" class="booleanOption"> + <label> + <input id="smoothScroll" type="checkbox"/> + Use smooth scrolling + </label> </td> </tr> <tr> @@ -303,13 +374,12 @@ unmapAll <td verticalAlign="top" class="booleanOption"> <div class="help"> <div class="example"> - After typing "F" to enter link hinting mode, this option lets you type the text of a link - to select it. + In link-hint mode, this option lets you also select a link by typing its text. </div> </div> <label> <input id="filterLinkHints" type="checkbox"/> - Use the link's name and numbers for link hint filtering + Use the link's name and numbers for link-hint filtering </label> </td> </tr> @@ -318,7 +388,7 @@ unmapAll <td verticalAlign="top" class="booleanOption"> <div class="help"> <div class="example"> - The Heads-Up Display appears when typing into text boxes. + When enabled, the HUD will not be displayed. </div> </div> <label> @@ -337,76 +407,97 @@ unmapAll </div> <label> <input id="regexFindMode" type="checkbox"/> - Treat find queries as regular expressions. + Treat find queries as regular expressions </label> </td> </tr> <tr> - <td class="caption">Previous Patterns</td> + <td class="caption">Previous patterns</td> <td verticalAlign="top"> <div class="help"> <div class="example"> - Vimium will match against these patterns when using the "navigate to the previous page" - command. + The "navigate to previous page" command uses these patterns to find the link to follow. </div> </div> <input id="previousPatterns" type="text" /> + <div class="nonEmptyTextOption"> </td> </tr> <tr> - <td class="caption">Next Patterns</td> + <td class="caption">Next patterns</td> <td verticalAlign="top"> <div class="help"> <div class="example"> - Vimium will match against these patterns when using the "navigate to the next page" command. + The "navigate to next page" command uses these patterns to find the link to follow. </div> </div> <input id="nextPatterns" type="text" /> + <div class="nonEmptyTextOption"> </td> </tr> <tr> - <td class="caption">Default Search<br/>Engine</td> + <td class="caption">New tab URL</td> <td verticalAlign="top"> <div class="help"> <div class="example"> - The search engine which is used when searching from the Vomnibar - (e.g.: "http://duckduckgo.com/?q="). + The page to open with the "create new tab" command. + Set this to "<tt>pages/blank.html</tt>" for a blank page.<br /> </div> </div> - <input id="searchUrl" type="text" /> + <input id="newTabUrl" type="text" /> + <div class="nonEmptyTextOption"> </td> </tr> <tr> - <td class="caption">Custom Search<br/>Engines</td> + <td class="caption">Default search<br/>engine</td> <td verticalAlign="top"> <div class="help"> <div class="example"> - Use this to add shortcuts for your common search engines when using the Vomnibar.<br/><br/> - The format is:<br/> - <pre>your-keyword: http://the-site.com/?q=%s</pre> - %s will be replaced with your search term.<br/> - Lines which start with "#" are comments. + The search engine to use in the Vomnibar <br> (e.g.: "http://duckduckgo.com/?q="). </div> </div> - <textarea id="searchEngines"></textarea> + <input id="searchUrl" type="text" /> + <div class="nonEmptyTextOption"> + </td> + </tr> + <tr> + <td class="caption">CSS for link hints</td> + <td verticalAlign="top"> + <div class="help"> + <div class="example"> + The CSS used to style the characters next to each link hint.<br/><br/> + These styles are used in addition to and take precedence over Vimium's + default styles. + </div> + </div> + <textarea id="userDefinedLinkHintCss" class="code" type="text"></textarea> + <div class="nonEmptyTextOption"> </td> </tr> </tbody> </table> + </div> - <div id="buttonsPanel"> - <a href="#" id="advancedOptionsLink">Show advanced options…</a> - <div id="buttonContainer"> - <button id="restoreSettings">Restore to Defaults</button> - <button id="saveOptions" disabled="true">Save Options</button> - </div> - </div> - - <br/> + <!-- Some extra space which is hidden underneath the footer. --> + <div id="endSpace"/> - <footer id="showHelpDialogMessage"> - To view all available shortcuts, type <strong>?</strong> to show the Vimium help dialog. - </footer> + <div id="footer"> + <div id="footerWrapper"> + <table id="footerTable"> + <tr> + <td id="footerTableData"> + <span id="helpText"> + Type <strong>?</strong> to show the Vimium help dialog. + <br/> + Type <strong>Ctrl-Enter</strong> to save <i>all</i> options. + </span> + </td> + <td id="saveOptionsTableData"> + <button id="saveOptions" disabled="true">No Changes</button> + </td> + </tr> + </table> + </div> </div> </body> </html> diff --git a/pages/popup.coffee b/pages/popup.coffee index ecf683e5..99a4eb87 100644 --- a/pages/popup.coffee +++ b/pages/popup.coffee @@ -3,7 +3,18 @@ originalRule = undefined originalPattern = undefined originalPassKeys = undefined -onLoad = -> +generateDefaultPattern = (url) -> + if /^https?:\/\/./.test url + # The common use case is to disable Vimium at the domain level. + # Generate "https?://www.example.com/*" from "http://www.example.com/path/to/page.html". + "https?:/" + url.split("/",3)[1..].join("/") + "/*" + else if /^[a-z]{3,}:\/\/./.test url + # Anything else which seems to be a URL. + url.split("/",3).join("/") + "/*" + else + url + "*" + +reset = (initialize=false) -> document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html") chrome.tabs.getSelected null, (tab) -> isEnabled = chrome.extension.getBackgroundPage().isEnabledForUrl(url: tab.url) @@ -13,14 +24,25 @@ onLoad = -> originalPattern = originalRule.pattern originalPassKeys = originalRule.passKeys else - # The common use case is to disable Vimium at the domain level. - # This regexp will match "http://www.example.com/" from "http://www.example.com/path/to/page.html". - domain = (tab.url.match(/[^\/]*\/\/[^\/]*\//) or tab.url) + "*" originalRule = null - originalPattern = domain + originalPattern = generateDefaultPattern tab.url originalPassKeys = "" - document.getElementById("popupPattern").value = originalPattern - document.getElementById("popupPassKeys").value = originalPassKeys + patternElement = document.getElementById("popupPattern") + passKeysElement = document.getElementById("popupPassKeys") + patternElement.value = originalPattern + passKeysElement.value = originalPassKeys + if initialize + # Activate <Ctrl-Enter> to save. + for element in [ patternElement, passKeysElement ] + element.addEventListener "keyup", (event) -> + if event.ctrlKey and event.keyCode == 13 + addExclusionRule() + window.close() + element.addEventListener "focus", -> document.getElementById("helpText").style.display = "block" + element.addEventListener "blur", -> document.getElementById("helpText").style.display = "none" + # Focus passkeys with cursor at the end (but only when creating popup). + passKeysElement.focus() + passKeysElement.setSelectionRange(passKeysElement.value.length, passKeysElement.value.length) onChange() onChange = -> @@ -71,18 +93,18 @@ addExclusionRule = -> passKeys = document.getElementById("popupPassKeys").value.trim() chrome.extension.getBackgroundPage().addExclusionRule pattern, passKeys showMessage("Updated.") - onLoad() + reset() removeExclusionRule = -> pattern = document.getElementById("popupPattern").value.trim() chrome.extension.getBackgroundPage().removeExclusionRule pattern showMessage("Removed.") - onLoad() + reset() document.addEventListener "DOMContentLoaded", -> document.getElementById("popupExclude").addEventListener "click", addExclusionRule, false document.getElementById("popupRemove").addEventListener "click", removeExclusionRule, false for field in ["popupPattern", "popupPassKeys"] - for event in ["keyup", "change"] + for event in ["input", "change"] document.getElementById(field).addEventListener event, onChange, false - onLoad() + reset true diff --git a/pages/popup.html b/pages/popup.html index 86982eae..775d6c07 100644 --- a/pages/popup.html +++ b/pages/popup.html @@ -36,7 +36,7 @@ list-style: none; } - #popupMenu a, #popupMenu a:active, #popupMenu a:visited { + #popupMenu li, #popupMenu a:active, #popupMenu a:visited { color: #3F6EC2; display: block; border-top: 1px solid #DDDDDD; @@ -51,6 +51,14 @@ #optionsLink { font-family : "Helvetica Neue", "Helvetica", "Arial", sans-serif; font-size: 12px; + float: right; + } + #helpText { + color: #979ca0; + font-family : "Helvetica Neue", "Helvetica", "Arial", sans-serif; + font-size: 12px; + float: left; + display: none; } </style> <script src="popup.js"></script> @@ -67,7 +75,10 @@ <div id="popupMenu"> <ul> - <li><a id="optionsLink" target="_blank">Options</a></li> + <li> + <span id="helpText">Type <strong>Ctrl-Enter</strong> to save and close.</span> + <a id="optionsLink" target="_blank">Options</a> + </li> </ul> </div> </div> diff --git a/pages/vomnibar.coffee b/pages/vomnibar.coffee index 59bf8266..2b6980f7 100644 --- a/pages/vomnibar.coffee +++ b/pages/vomnibar.coffee @@ -169,7 +169,7 @@ class VomnibarUI @hide() # It seems like we have to manually suppress the event here and still return true. - event.stopPropagation() + event.stopImmediatePropagation() event.preventDefault() true diff --git a/tests/dom_tests/dom_tests.coffee b/tests/dom_tests/dom_tests.coffee index ac3f9ebe..4a61877c 100644 --- a/tests/dom_tests/dom_tests.coffee +++ b/tests/dom_tests/dom_tests.coffee @@ -8,7 +8,7 @@ mockKeyboardEvent = (keyChar) -> event.charCode = (if keyCodes[keyChar] isnt undefined then keyCodes[keyChar] else keyChar.charCodeAt(0)) event.keyIdentifier = "U+00" + event.charCode.toString(16) event.keyCode = event.charCode - event.stopPropagation = -> + event.stopImmediatePropagation = -> event.preventDefault = -> event diff --git a/tests/dom_tests/vomnibar_test.coffee b/tests/dom_tests/vomnibar_test.coffee index f7241552..f660f96b 100644 --- a/tests/dom_tests/vomnibar_test.coffee +++ b/tests/dom_tests/vomnibar_test.coffee @@ -52,7 +52,7 @@ context "Keep selection within bounds", eventMock = preventDefault: -> - stopPropagation: -> + stopImmediatePropagation: -> @completions = [{html:'foo',type:'tab',url:'http://example.com'}] ui.update(true) diff --git a/tests/unit_tests/commands_test.coffee b/tests/unit_tests/commands_test.coffee index c10c643b..daaef016 100644 --- a/tests/unit_tests/commands_test.coffee +++ b/tests/unit_tests/commands_test.coffee @@ -1,8 +1,5 @@ -root.chrome = - session: - MAX_SESSION_RESULTS: 25 - require "./test_helper.js" +extend global, require "./test_chrome_stubs.js" {Commands} = require "../../background_scripts/commands.js" context "Key mappings", diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index 811436a9..e4966016 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -1,8 +1,8 @@ require "./test_helper.js" extend(global, require "../../lib/utils.js") extend(global, require "../../background_scripts/completion.js") +extend global, require "./test_chrome_stubs.js" -global.chrome = {} global.document = createElement: -> {} @@ -163,13 +163,13 @@ context "domain completer", should "return only a single matching domain", -> results = filterCompleter(@completer, ["story"]) - assert.arrayEqual ["history1.com"], results.map (result) -> result.url + assert.arrayEqual ["http://history1.com"], results.map (result) -> result.url should "pick domains which are more recent", -> # These domains are the same except for their last visited time. - assert.equal "history1.com", filterCompleter(@completer, ["story"])[0].url + assert.equal "http://history1.com", filterCompleter(@completer, ["story"])[0].url @history2.lastVisitTime = hours(3) - assert.equal "history2.com", filterCompleter(@completer, ["story"])[0].url + assert.equal "http://history2.com", filterCompleter(@completer, ["story"])[0].url should "returns no results when there's more than one query term, because clearly it's not a domain", -> assert.arrayEqual [], filterCompleter(@completer, ["his", "tory"]) @@ -194,15 +194,15 @@ context "domain completer (removing entries)", should "remove 1 entry for domain with reference count of 1", -> @onVisitRemovedListener { allHistory: false, urls: [@history1.url] } - assert.equal "history2.com", filterCompleter(@completer, ["story"])[0].url + assert.equal "http://history2.com", filterCompleter(@completer, ["story"])[0].url assert.equal 0, filterCompleter(@completer, ["story1"]).length should "remove 2 entries for domain with reference count of 2", -> @onVisitRemovedListener { allHistory: false, urls: [@history2.url] } - assert.equal "history2.com", filterCompleter(@completer, ["story2"])[0].url + assert.equal "http://history2.com", filterCompleter(@completer, ["story2"])[0].url @onVisitRemovedListener { allHistory: false, urls: [@history3.url] } assert.equal 0, filterCompleter(@completer, ["story2"]).length - assert.equal "history1.com", filterCompleter(@completer, ["story"])[0].url + assert.equal "http://history1.com", filterCompleter(@completer, ["story"])[0].url should "remove 3 (all) matching domain entries", -> @onVisitRemovedListener { allHistory: false, urls: [@history2.url] } @@ -399,6 +399,58 @@ context "RegexpCache", should "search for a string with a prefix/suffix (negative case)", -> assert.isTrue "hound dog".search(RegexpCache.get("do", "\\b", "\\b")) == -1 +fakeTimeDeltaElapsing = -> + +context "TabRecency", + setup -> + @tabRecency = new TabRecency() + + fakeTimeDeltaElapsing = => + if @tabRecency.lastVisitedTime? + @tabRecency.lastVisitedTime = new Date(@tabRecency.lastVisitedTime - @tabRecency.timeDelta) + + @tabRecency.register 3 + fakeTimeDeltaElapsing() + @tabRecency.register 2 + fakeTimeDeltaElapsing() + @tabRecency.register 9 + fakeTimeDeltaElapsing() + @tabRecency.register 1 + @tabRecency.deregister 9 + fakeTimeDeltaElapsing() + @tabRecency.register 4 + fakeTimeDeltaElapsing() + + should "have entries for recently active tabs", -> + assert.isTrue @tabRecency.cache[1] + assert.isTrue @tabRecency.cache[2] + assert.isTrue @tabRecency.cache[3] + + should "not have entries for removed tabs", -> + assert.isFalse @tabRecency.cache[9] + + should "give a high score to the most recent tab", -> + assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 1 + assert.isTrue @tabRecency.recencyScore(3) < @tabRecency.recencyScore 1 + assert.isTrue @tabRecency.recencyScore(2) < @tabRecency.recencyScore 1 + + should "give a low score to the current tab", -> + assert.isTrue @tabRecency.recencyScore(1) > @tabRecency.recencyScore 4 + assert.isTrue @tabRecency.recencyScore(2) > @tabRecency.recencyScore 4 + assert.isTrue @tabRecency.recencyScore(3) > @tabRecency.recencyScore 4 + + should "rank tabs by recency", -> + assert.isTrue @tabRecency.recencyScore(3) < @tabRecency.recencyScore 2 + assert.isTrue @tabRecency.recencyScore(2) < @tabRecency.recencyScore 1 + @tabRecency.register 3 + fakeTimeDeltaElapsing() + @tabRecency.register 4 # Making 3 the most recent tab which isn't the current tab. + assert.isTrue @tabRecency.recencyScore(1) < @tabRecency.recencyScore 3 + assert.isTrue @tabRecency.recencyScore(2) < @tabRecency.recencyScore 3 + assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 3 + assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 1 + assert.isTrue @tabRecency.recencyScore(4) < @tabRecency.recencyScore 2 + # A convenience wrapper around completer.filter() so it can be called synchronously in tests. filterCompleter = (completer, queryTerms) -> results = [] diff --git a/tests/unit_tests/exclusion_test.coffee b/tests/unit_tests/exclusion_test.coffee index a24c3b67..25bd8125 100644 --- a/tests/unit_tests/exclusion_test.coffee +++ b/tests/unit_tests/exclusion_test.coffee @@ -1,13 +1,13 @@ require "./test_helper.js" -require "./test_chrome_stubs.js" +extend global, 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 = +root.Marks = create: () -> true goto: bind: () -> true diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee index 1283497c..4625457b 100644 --- a/tests/unit_tests/settings_test.coffee +++ b/tests/unit_tests/settings_test.coffee @@ -1,5 +1,5 @@ require "./test_helper.js" -require "./test_chrome_stubs.js" +extend global, require "./test_chrome_stubs.js" extend(global, require "../../lib/utils.js") Utils.getCurrentVersion = -> '1.44' diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index 9622f85f..80750337 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -5,10 +5,10 @@ # It also provides stubs for a number of other chrome APIs. # -global.window = {} -global.localStorage = {} +exports.window = {} +exports.localStorage = {} -global.chrome = +exports.chrome = runtime: getManifest: () -> version: "1.2.3" @@ -30,6 +30,10 @@ global.chrome = addListener: () -> true onActiveChanged: addListener: () -> true + onActivated: + addListener: () -> true + onReplaced: + addListener: () -> true query: () -> true windows: @@ -56,6 +60,9 @@ global.chrome = items[key] = {} @func(items,'synced storage stub') + session: + MAX_SESSION_RESULTS: 25 + # chrome.storage.sync sync: store: {} diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee index c4139dbb..556f5b7a 100644 --- a/tests/unit_tests/utils_test.coffee +++ b/tests/unit_tests/utils_test.coffee @@ -1,5 +1,5 @@ require "./test_helper.js" -require "./test_chrome_stubs.js" +extend global, require "./test_chrome_stubs.js" extend(global, require "../../lib/utils.js") Utils.getCurrentVersion = -> '1.43' extend(global, require "../../background_scripts/sync.js") @@ -47,6 +47,27 @@ context "convertToUrl", assert.equal "http://www.google.com/search?q=google", Utils.convertToUrl("google") assert.equal "http://www.google.com/search?q=go%20ogle.com", Utils.convertToUrl("go ogle.com") +context "hasChromePrefix", + should "detect chrome prefixes of URLs", -> + assert.isTrue Utils.hasChromePrefix "about:foobar" + assert.isTrue Utils.hasChromePrefix "view-source:foobar" + assert.isTrue Utils.hasChromePrefix "chrome-extension:foobar" + assert.isTrue Utils.hasChromePrefix "data:foobar" + assert.isTrue Utils.hasChromePrefix "data:" + assert.isFalse Utils.hasChromePrefix "" + assert.isFalse Utils.hasChromePrefix "about" + assert.isFalse Utils.hasChromePrefix "view-source" + assert.isFalse Utils.hasChromePrefix "chrome-extension" + assert.isFalse Utils.hasChromePrefix "data" + assert.isFalse Utils.hasChromePrefix "data :foobar" + +context "isUrl", + should "identify URLs as URLs", -> + assert.isTrue Utils.isUrl "http://www.example.com/blah" + + should "identify non-URLs and non-URLs", -> + assert.isFalse Utils.isUrl "http://www.example.com/ blah" + context "Function currying", should "Curry correctly", -> foo = (a, b) -> "#{a},#{b}" |
