diff options
27 files changed, 1238 insertions, 499 deletions
diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index d62f82fe..d6402019 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -324,11 +324,17 @@ class SearchEngineCompleter searchEngines: {} filter: (queryTerms, onComplete) -> - searchEngineMatch = this.getSearchEngineMatches(queryTerms[0]) + {url: url, description: description} = @getSearchEngineMatches queryTerms suggestions = [] - if searchEngineMatch - searchEngineMatch = searchEngineMatch.replace(/%s/g, Utils.createSearchQuery queryTerms[1..]) - suggestion = new Suggestion(queryTerms, "search", searchEngineMatch, queryTerms[0] + ": " + queryTerms[1..].join(" "), @computeRelevancy) + if url + url = url.replace(/%s/g, Utils.createSearchQuery queryTerms[1..]) + if description + type = description + query = queryTerms[1..].join " " + else + type = "search" + query = queryTerms[0] + ": " + queryTerms[1..].join(" ") + suggestion = new Suggestion(queryTerms, type, url, query, @computeRelevancy) suggestions.push(suggestion) onComplete(suggestions) @@ -337,8 +343,8 @@ class SearchEngineCompleter refresh: -> this.searchEngines = root.Settings.getSearchEngines() - getSearchEngineMatches: (queryTerm) -> - this.searchEngines[queryTerm] + getSearchEngineMatches: (queryTerms) -> + (1 < queryTerms.length and @searchEngines[queryTerms[0]]) or {} # A completer which calls filter() on many completers, aggregates the results, ranks them, and returns the top # 10. Queries from the vomnibar frontend script come through a multi completer. diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index cebb38ca..4c1b9ae7 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -19,6 +19,11 @@ namedKeyRegex = /^(<(?:[amc]-.|(?:[amc]-)?[a-z0-9]{2,5})>)(.*)$/ selectionChangedHandlers = [] tabLoadedHandlers = {} # tabId -> function() +# A secret, available only within the current instantiation of Vimium. The secret is big, likely unguessable +# in practice, but less than 2^31. +chrome.storage.local.set + vimiumSecret: Math.floor Math.random() * 2000000000 + completionSources = bookmarks: new BookmarkCompleter() history: new HistoryCompleter() @@ -609,24 +614,24 @@ portHandlers = filterCompleter: filterCompleter sendRequestHandlers = - getCompletionKeys: getCompletionKeysRequest, - getCurrentTabUrl: getCurrentTabUrl, - openUrlInNewTab: openUrlInNewTab, - openUrlInIncognito: openUrlInIncognito, - openUrlInCurrentTab: openUrlInCurrentTab, - openOptionsPageInNewTab: openOptionsPageInNewTab, - registerFrame: registerFrame, - unregisterFrame: unregisterFrame, - frameFocused: handleFrameFocused, + getCompletionKeys: getCompletionKeysRequest + getCurrentTabUrl: getCurrentTabUrl + openUrlInNewTab: openUrlInNewTab + openUrlInIncognito: openUrlInIncognito + openUrlInCurrentTab: openUrlInCurrentTab + openOptionsPageInNewTab: openOptionsPageInNewTab + registerFrame: registerFrame + unregisterFrame: unregisterFrame + frameFocused: handleFrameFocused nextFrame: (request) -> BackgroundCommands.nextFrame 1, request.frameId - upgradeNotificationClosed: upgradeNotificationClosed, - updateScrollPosition: handleUpdateScrollPosition, - copyToClipboard: copyToClipboard, - isEnabledForUrl: isEnabledForUrl, - saveHelpDialogSettings: saveHelpDialogSettings, - selectSpecificTab: selectSpecificTab, - refreshCompleter: refreshCompleter, - createMark: Marks.create.bind(Marks), + upgradeNotificationClosed: upgradeNotificationClosed + updateScrollPosition: handleUpdateScrollPosition + copyToClipboard: copyToClipboard + isEnabledForUrl: isEnabledForUrl + saveHelpDialogSettings: saveHelpDialogSettings + selectSpecificTab: selectSpecificTab + refreshCompleter: refreshCompleter + createMark: Marks.create.bind(Marks) gotoMark: Marks.goto.bind(Marks) # Convenience function for development use. diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index e90bc1f8..2fc3b43d 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -46,13 +46,19 @@ root.Settings = Settings = # this is a map that we use to store our search engines for use. searchEnginesMap: {} - # this parses the search engines settings and clears the old searchEngines and sets the new one + # Parse the custom search engines setting and cache it. parseSearchEngines: (searchEnginesText) -> @searchEnginesMap = {} - # find the split pairs by first splitting by line then splitting on the first `: ` - split_pairs = ( pair.split( /: (.+)/, 2) for pair in searchEnginesText.split( /\n/ ) when pair[0] != "#" ) - @searchEnginesMap[a[0]] = a[1] for a in split_pairs - @searchEnginesMap + for line in searchEnginesText.split /\n/ + tokens = line.trim().split /\s+/ + continue if tokens.length < 2 or tokens[0].startsWith('"') or tokens[0].startsWith("#") + keywords = tokens[0].split ":" + continue unless keywords.length == 2 and not keywords[1] # So, like: [ "w", "" ]. + @searchEnginesMap[keywords[0]] = + url: tokens[1] + description: tokens[2..].join(" ") + + # Fetch the search-engine map, building it if necessary. getSearchEngines: -> this.parseSearchEngines(@get("searchEngines") || "") if Object.keys(@searchEnginesMap).length == 0 @searchEnginesMap @@ -105,7 +111,7 @@ root.Settings = Settings = # default/fall back search engine 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" + searchEngines: "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s wikipedia" newTabUrl: "chrome://newtab" settingsVersion: Utils.getCurrentVersion() diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee index 24bd7126..9f21d109 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -36,17 +36,6 @@ LinkHints = # init: -> - # - # Generate an XPath describing what a clickable element is. - # The final expression will be something like "//button | //xhtml:button | ..." - # We use translate() instead of lower-case() because Chrome only supports XPath 1.0. - # - clickableElementsXPath: DomUtils.makeXPath( - ["a", "area[@href]", "textarea", "button", "select", - "input[not(@type='hidden' or @disabled or @readonly)]", - "*[@onclick or @tabindex or @role='link' or @role='button' or contains(@class, 'button') or " + - "@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"]) - # We need this as a top-level function because our command system doesn't yet support arguments. activateModeToOpenInNewTab: -> @activateMode(OPEN_IN_NEW_BG_TAB) activateModeToOpenInNewForegroundTab: -> @activateMode(OPEN_IN_NEW_FG_TAB) @@ -136,45 +125,128 @@ LinkHints = marker # - # Returns all clickable elements that are not hidden and are in the current viewport. - # We prune invisible elements partly for performance reasons, but moreso it's to decrease the number - # of digits needed to enumerate all of the links on screen. + # Determine whether the element is visible and clickable. If it is, find the rect bounding the element in + # the viewport. There may be more than one part of element which is clickable (for example, if it's an + # image), therefore we always return a array of element/rect pairs (which may also be a singleton or empty). # - getVisibleClickableElements: -> - resultSet = DomUtils.evaluateXPath(@clickableElementsXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) - + getVisibleClickable: (element) -> + tagName = element.tagName.toLowerCase() + isClickable = false + onlyHasTabIndex = false visibleElements = [] - # Find all visible clickable elements. - for i in [0...resultSet.snapshotLength] by 1 - element = resultSet.snapshotItem(i) - clientRect = DomUtils.getVisibleClientRect(element, clientRect) - if (clientRect != null) - visibleElements.push({element: element, rect: clientRect}) - - if (element.localName == "area") - map = element.parentElement - continue unless map - img = document.querySelector("img[usemap='#" + map.getAttribute("name") + "']") - continue unless img - imgClientRects = img.getClientRects() - continue if (imgClientRects.length == 0) - c = element.coords.split(/,/) - coords = [parseInt(c[0], 10), parseInt(c[1], 10), parseInt(c[2], 10), parseInt(c[3], 10)] - rect = { - top: imgClientRects[0].top + coords[1], - left: imgClientRects[0].left + coords[0], - right: imgClientRects[0].left + coords[2], - bottom: imgClientRects[0].top + coords[3], - width: coords[2] - coords[0], - height: coords[3] - coords[1] - } - - visibleElements.push({element: element, rect: rect}) + # Insert area elements that provide click functionality to an img. + if tagName == "img" + mapName = element.getAttribute "usemap" + if mapName + imgClientRects = element.getClientRects() + mapName = mapName.replace(/^#/, "").replace("\"", "\\\"") + map = document.querySelector "map[name=\"#{mapName}\"]" + if map and imgClientRects.length > 0 + areas = map.getElementsByTagName "area" + areasAndRects = DomUtils.getClientRectsForAreas imgClientRects[0], areas + visibleElements.push areasAndRects... + + # Check aria properties to see if the element should be ignored. + if (element.getAttribute("aria-hidden")?.toLowerCase() in ["", "true"] or + element.getAttribute("aria-disabled")?.toLowerCase() in ["", "true"]) + return [] # This element should never have a link hint. + + # Check for attributes that make an element clickable regardless of its tagName. + if (element.hasAttribute("onclick") or + element.getAttribute("role")?.toLowerCase() in ["button", "link"] or + element.getAttribute("class")?.toLowerCase().indexOf("button") >= 0 or + element.getAttribute("contentEditable")?.toLowerCase() in ["", "contentEditable", "true"]) + isClickable = true + + # Check for jsaction event listeners on the element. + if element.hasAttribute "jsaction" + jsactionRules = element.getAttribute("jsaction").split(";") + for jsactionRule in jsactionRules + ruleSplit = jsactionRule.split ":" + isClickable ||= ruleSplit[0] == "click" or (ruleSplit.length == 1 and ruleSplit[0] != "none") + + # Check for tagNames which are natively clickable. + switch tagName + when "a" + isClickable = true + when "textarea" + isClickable ||= not element.disabled and not element.readOnly + when "input" + isClickable ||= not (element.getAttribute("type")?.toLowerCase() == "hidden" or + element.disabled or + (element.readOnly and DomUtils.isSelectable element)) + when "button", "select" + isClickable ||= not element.disabled + + # Elements with tabindex are sometimes useful, but usually not. We can treat them as second class + # citizens when it improves UX, so take special note of them. + tabIndexValue = element.getAttribute("tabindex") + tabIndex = if tabIndexValue == "" then 0 else parseInt tabIndexValue + unless isClickable or isNaN(tabIndex) or tabIndex < 0 + isClickable = onlyHasTabIndex = true + + if isClickable + clientRect = DomUtils.getVisibleClientRect element + if clientRect != null + visibleElements.push {element: element, rect: clientRect, secondClassCitizen: onlyHasTabIndex} visibleElements # + # Returns all clickable elements that are not hidden and are in the current viewport, along with rectangles + # at which (parts of) the elements are displayed. + # In the process, we try to find rects where elements do not overlap so that link hints are unambiguous. + # Because of this, the rects returned will frequently *NOT* be equivalent to the rects for the whole + # element. + # + getVisibleClickableElements: -> + elements = document.documentElement.getElementsByTagName "*" + visibleElements = [] + + # The order of elements here is important; they should appear in the order they are in the DOM, so that + # we can work out which element is on top when multiple elements overlap. Detecting elements in this loop + # is the sensible, efficient way to ensure this happens. + # NOTE(mrmr1993): Our previous method (combined XPath and DOM traversal for jsaction) couldn't provide + # this, so it's necessary to check whether elements are clickable in order, as we do below. + for element in elements + visibleElement = @getVisibleClickable element + visibleElements.push visibleElement... + + # TODO(mrmr1993): Consider z-index. z-index affects behviour as follows: + # * The document has a local stacking context. + # * An element with z-index specified + # - sets its z-order position in the containing stacking context, and + # - creates a local stacking context containing its children. + # * An element (1) is shown above another element (2) if either + # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), the + # ancestor of (1) has a higher z-index than the ancestor of (2); or + # - in the last stacking context which contains both an ancestor of (1) and an ancestor of (2), + # + the ancestors of (1) and (2) have equal z-index, and + # + the ancestor of (1) appears later in the DOM than the ancestor of (2). + # + # Remove rects from elements where another clickable element lies above it. + nonOverlappingElements = [] + # Traverse the DOM from first to last, since later elements show above earlier elements. + visibleElements = visibleElements.reverse() + while visibleElement = visibleElements.pop() + rects = [visibleElement.rect] + for {rect: negativeRect} in visibleElements + # Subtract negativeRect from every rect in rects, and concatenate the arrays of rects that result. + rects = [].concat (rects.map (rect) -> Rect.subtract rect, negativeRect)... + if rects.length > 0 + nonOverlappingElements.push {element: visibleElement.element, rect: rects[0]} + else + # Every part of the element is covered by some other element, so just insert the whole element's + # rect. Except for elements with tabIndex set (second class citizens); these are often more trouble + # than they're worth. + # TODO(mrmr1993): This is probably the wrong thing to do, but we don't want to stop being able to + # click some elements that we could click before. + nonOverlappingElements.push visibleElement unless visibleElement.secondClassCitizen + + nonOverlappingElements + + # # Handles shift and esc keys. The other keys are passed to getMarkerMatcher().matchHintsByKey. # onKeyDownInMode: (hintMarkers, event) -> diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 09470158..889dc042 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -5,6 +5,10 @@ activatedElement = null # Return 0, -1 or 1: the sign of the argument. +# NOTE(smblott; 2014/12/17) We would like to use Math.sign(). However, according to this site +# (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sign) Math.sign() was +# only introduced in Chrome 38. This caused problems in R1.48 for users with old Chrome installations. We +# can replace this with Math.sign() at some point. getSign = (val) -> if not val 0 @@ -63,7 +67,7 @@ shouldScroll = (element, direction) -> # Instead, we scroll the element by 1 or -1 and see if it moved (then put it back). :factor is the factor by # which :scrollBy and :scrollTo will later scale the scroll amount. :factor can be negative, so we need it # here in order to decide whether we should test a forward scroll or a backward scroll. -# Bug verified in Chrome 38.0.2125.104. +# Bug last verified in Chrome 38.0.2125.104. doesScroll = (element, direction, amount, factor) -> # amount is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls (only # gg, G, and friends), amount can be either a string ("max" or "viewSize") or zero. In the former case, @@ -80,6 +84,19 @@ findScrollableElement = (element, direction, amount, factor) -> element = element.parentElement || document.body element +# On some pages, document.body is not scrollable. Here, we search the document for the largest visible +# element which does scroll vertically. This is used to initialize activatedElement. See #1358. +firstScrollableElement = (element=document.body) -> + if doesScroll(element, "y", 1, 1) or doesScroll(element, "y", -1, 1) + element + else + children = ({element: child, rect: DomUtils.getVisibleClientRect(child)} for child in element.children) + children = children.filter (child) -> child.rect # Filter out non-visible elements. + children.map (child) -> child.area = child.rect.width * child.rect.height + for child in children.sort((a,b) -> b.area - a.area) # Largest to smallest by visible area. + return ele if ele = firstScrollableElement child.element + null + checkVisibility = (element) -> # If the activated element has been scrolled completely offscreen, then subsequent changes in its scroll # position will not provide any more visual feedback to the user. Therefore, we deactivate it so that @@ -202,7 +219,7 @@ Scroller = window.scrollBy(0, amount) return - activatedElement ||= document.body + activatedElement ||= document.body and firstScrollableElement() return unless activatedElement # Avoid the expensive scroll calculation if it will not be used. This reduces costs during smooth, @@ -213,8 +230,8 @@ Scroller = CoreScroller.scroll element, direction, elementAmount scrollTo: (direction, pos) -> - return unless document.body or activatedElement - activatedElement ||= document.body + activatedElement ||= document.body and firstScrollableElement() + return unless activatedElement element = findScrollableElement activatedElement, direction, pos, 1 amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] diff --git a/content_scripts/ui_component.coffee b/content_scripts/ui_component.coffee new file mode 100644 index 00000000..c4ed3bf6 --- /dev/null +++ b/content_scripts/ui_component.coffee @@ -0,0 +1,54 @@ +class UIComponent + iframeElement: null + iframePort: null + showing: null + + constructor: (iframeUrl, className, @handleMessage) -> + @iframeElement = document.createElement "iframe" + @iframeElement.className = className + @iframeElement.seamless = "seamless" + @iframeElement.src = chrome.runtime.getURL iframeUrl + @iframeElement.addEventListener "load", => @openPort() + document.documentElement.appendChild @iframeElement + @showing = true # The iframe is visible now. + # Hide the iframe, but don't interfere with the focus. + @hide false + + # Open a port and pass it to the iframe via window.postMessage. + openPort: -> + messageChannel = new MessageChannel() + @iframePort = messageChannel.port1 + @iframePort.onmessage = (event) => @handleMessage event + + # Get vimiumSecret so the iframe can determine that our message isn't the page impersonating us. + chrome.storage.local.get "vimiumSecret", ({vimiumSecret: secret}) => + @iframeElement.contentWindow.postMessage secret, chrome.runtime.getURL(""), [messageChannel.port2] + + postMessage: (message) -> + @iframePort.postMessage message + + activate: (message) -> + @postMessage message if message? + if @showing + # NOTE(smblott) Experimental. Not sure this is a great idea. If the iframe was already showing, then + # the user gets no visual feedback when it is re-focused. So flash its border. + @iframeElement.classList.add "vimiumUIComponentReactivated" + setTimeout((=> @iframeElement.classList.remove "vimiumUIComponentReactivated"), 200) + else + @show() + @iframeElement.focus() + + show: (message) -> + @postMessage message if message? + @iframeElement.classList.remove "vimiumUIComponentHidden" + @iframeElement.classList.add "vimiumUIComponentShowing" + @showing = true + + hide: (focusWindow = true)-> + @iframeElement.classList.remove "vimiumUIComponentShowing" + @iframeElement.classList.add "vimiumUIComponentHidden" + window.focus() if focusWindow + @showing = false + +root = exports ? window +root.UIComponent = UIComponent diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css index cdf47d6c..ec1a09e6 100644 --- a/content_scripts/vimium.css +++ b/content_scripts/vimium.css @@ -271,140 +271,47 @@ div.vimiumHUD a.close-button:hover { body.vimiumFindMode ::selection { background: #ff9632; -}; +} -/* Vomnibar CSS */ +/* Vomnibar Frame CSS */ -#vomnibar ol, #vomnibar ul { - list-style: none; - display: block; -} +iframe.vomnibarFrame { + background-color: transparent; + padding: 0px; + overflow: hidden; -#vomnibar { display: block; position: fixed; - width: 80%; + width: calc(80% + 20px); /* same adjustment as in pages/vomnibar.coffee */ min-width: 400px; + height: calc(100% - 70px); top: 70px; left: 50%; margin: 0 0 0 -40%; + border: none; font-family: sans-serif; - background: #F1F1F1; - text-align: left; - border-radius: 4px; - box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8); - border: 1px solid #aaa; /* One less than hint markers and the help dialog. */ z-index: 2147483645; } -#vomnibar input { - color: #000; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-size: 20px; - height: 34px; - margin-bottom: 0; - padding: 4px; - background-color: white; - border-radius: 3px; - border: 1px solid #E8E8E8; - box-shadow: #444 0px 0px 1px; - width: 100%; - outline: none; - box-sizing: border-box; -} - -#vomnibar .vomnibarSearchArea { - display: block; - padding: 10px; - background-color: #F1F1F1; - border-radius: 4px 4px 0 0; - border-bottom: 1px solid #C6C9CE; -} - -#vomnibar ul { - background-color: white; - border-radius: 0 0 4px 4px; - list-style: none; - padding: 10px 0; - padding-top: 0; -} - -#vomnibar li { - border-bottom: 1px solid #ddd; - line-height: 1.1em; - padding: 7px 10px; - font-size: 16px; - color: black; - position: relative; - display: list-item; - margin: auto; -} - -#vomnibar li:last-of-type { - border-bottom: none; -} - -#vomnibar li .vomnibarTopHalf, #vomnibar li .vomnibarBottomHalf { - display: block; - overflow: hidden; -} - -#vomnibar li .vomnibarBottomHalf { - font-size: 15px; - margin-top: 3px; - padding: 2px 0; -} - -#vomnibar li .vomnibarSource { - color: #777; - margin-right: 4px; -} -#vomnibar li .vomnibarRelevancy { +div#vimiumFlash { + box-shadow: 0px 0px 4px 2px #4183C4; + padding: 1px; + background-color: transparent; position: absolute; - right: 0; - top: 0; - padding: 5px; - background-color: white; - color: black; - font-family: monospace; - width: 100px; - overflow: hidden; -} - -#vomnibar li .vomnibarUrl { - white-space: nowrap; - color: #224684; -} - -#vomnibar li .vomnibarMatch { - font-weight: bold; - color: black; + z-index: 2147483648; } -#vomnibar li em, #vomnibar li .vomnibarTitle { - color: black; - margin-left: 4px; - font-weight: normal; -} -#vomnibar li em { font-style: italic; } -#vomnibar li em .vomnibarMatch, #vomnibar li .vomnibarTitle .vomnibarMatch { - color: #333; - text-decoration: underline; +/* UIComponent CSS */ +iframe.vimiumUIComponentHidden { + display: none; } -#vomnibar li.vomnibarSelected { - background-color: #BBCEE9; - font-weight: normal; +iframe.vimiumUIComponentVisible { + display: block; } - - -div#vimiumFlash { - box-shadow: 0px 0px 4px 2px #4183C4; - padding: 1px; - background-color: transparent; - position: absolute; - z-index: 2147483648; +iframe.vimiumUIComponentReactivated { + border: 5px solid yellow; } diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 1f116f88..351a2690 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -57,6 +57,15 @@ settings = @port = chrome.runtime.connect({ name: "settings" }) @port.onMessage.addListener(@receiveMessage) + # If the port is closed, the background page has gone away (since we never close it ourselves). Stub the + # settings object so we don't keep trying to connect to the extension even though it's gone away. + @port.onDisconnect.addListener => + @port = null + for own property, value of this + # @get doesn't depend on @port, so we can continue to support it to try and reduce errors. + @[property] = (->) if "function" == typeof value and property != "get" + + get: (key) -> @values[key] set: (key, value) -> @@ -109,6 +118,13 @@ initializePreDomReady = -> # Send the key to the key handler in the background page. keyPort = chrome.runtime.connect({ name: "keyDown" }) + # If the port is closed, the background page has gone away (since we never close it ourselves). Disable all + # our event listeners, and stub out chrome.runtime.sendMessage/connect (to prevent errors). + # TODO(mrmr1993): Do some actual cleanup to free resources, hide UI, etc. + keyPort.onDisconnect.addListener -> + isEnabledForUrl = false + chrome.runtime.sendMessage = -> + chrome.runtime.connect = -> requestHandlers = hideUpgradeNotification: -> HUD.hideUpgradeNotification() @@ -183,6 +199,8 @@ initializeOnDomReady = -> # Tell the background page we're in the dom ready state. chrome.runtime.connect({ name: "domReady" }) + CursorHider.init() + Vomnibar.init() registerFrame = -> # Don't register frameset containers; focusing them is no use. @@ -436,15 +454,16 @@ onKeydown = (event) -> keyChar = "<" + keyChar + ">" if (isInsertMode() && KeyboardUtils.isEscape(event)) - # Note that we can't programmatically blur out of Flash embeds from Javascript. - if (!isEmbed(event.srcElement)) + if isEditable(event.srcElement) or isEmbed(event.srcElement) # Remove focus so the user can't just get himself back into insert mode by typing in the same input # box. - if (isEditable(event.srcElement)) - event.srcElement.blur() - exitInsertMode() - DomUtils.suppressEvent event - KeydownEvents.push event + # NOTE(smblott, 2014/12/22) Including embeds for .blur() etc. here is experimental. It appears to be + # the right thing to do for most common use cases. However, it could also cripple flash-based sites and + # games. See discussion in #1211 and #1194. + event.srcElement.blur() + exitInsertMode() + DomUtils.suppressEvent event + KeydownEvents.push event else if (findMode) if (KeyboardUtils.isEscape(event)) @@ -1086,6 +1105,40 @@ Tween = value = (elapsed / state.duration) * (state.to - state.from) + state.from state.onUpdate(value) +CursorHider = + # + # Hide the cursor when the browser scrolls, and prevent mouse from hovering while invisible. + # + cursorHideStyle: null + isScrolling: false + + onScroll: (event) -> + CursorHider.isScrolling = true + unless CursorHider.cursorHideStyle.parentElement + document.head.appendChild CursorHider.cursorHideStyle + + onMouseMove: (event) -> + if CursorHider.cursorHideStyle.parentElement and not CursorHider.isScrolling + CursorHider.cursorHideStyle.remove() + CursorHider.isScrolling = false + + init: -> + # Temporarily disabled pending consideration of #1359 (in particular, whether cursor hiding is too fragile + # as to provide a consistent UX). + return + + # Disable cursor hiding for Chrome versions less than 39.0.2171.71 due to a suspected browser error. + # See #1345 and #1348. + return unless Utils.haveChromeVersion "39.0.2171.71" + + @cursorHideStyle = document.createElement("style") + @cursorHideStyle.innerHTML = """ + body * {pointer-events: none !important; cursor: none !important;} + body, html {cursor: none !important;} + """ + window.addEventListener "mousemove", @onMouseMove + window.addEventListener "scroll", @onScroll + initializePreDomReady() window.addEventListener("DOMContentLoaded", registerFrame) window.addEventListener("unload", unregisterFrame) diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee index f1d2ccc5..6381fd7f 100644 --- a/content_scripts/vomnibar.coffee +++ b/content_scripts/vomnibar.coffee @@ -1,245 +1,51 @@ -Vomnibar = - vomnibarUI: null # the dialog instance for this window - completers: {} - - getCompleter: (name) -> - if (!(name of @completers)) - @completers[name] = new BackgroundCompleter(name) - @completers[name] - - # - # Activate the Vomnibox. - # - activateWithCompleter: (completerName, refreshInterval, initialQueryValue, selectFirstResult, forceNewTab) -> - completer = @getCompleter(completerName) - @vomnibarUI = new VomnibarUI() unless @vomnibarUI - completer.refresh() - @vomnibarUI.setInitialSelectionValue(if selectFirstResult then 0 else -1) - @vomnibarUI.setCompleter(completer) - @vomnibarUI.setRefreshInterval(refreshInterval) - @vomnibarUI.setForceNewTab(forceNewTab) - @vomnibarUI.show() - if (initialQueryValue) - @vomnibarUI.setQuery(initialQueryValue) - @vomnibarUI.update() - - activate: -> @activateWithCompleter("omni", 100) - activateInNewTab: -> @activateWithCompleter("omni", 100, null, false, true) - activateTabSelection: -> @activateWithCompleter("tabs", 0, null, true) - activateBookmarks: -> @activateWithCompleter("bookmarks", 0, null, true) - activateBookmarksInNewTab: -> @activateWithCompleter("bookmarks", 0, null, true, true) - activateEditUrl: -> @activateWithCompleter("omni", 100, window.location.href) - activateEditUrlInNewTab: -> @activateWithCompleter("omni", 100, window.location.href, false, true) - getUI: -> @vomnibarUI - - -class VomnibarUI - constructor: -> - @refreshInterval = 0 - @initDom() - - setQuery: (query) -> @input.value = query - - setInitialSelectionValue: (initialSelectionValue) -> - @initialSelectionValue = initialSelectionValue - - setCompleter: (completer) -> - @completer = completer - @reset() - - setRefreshInterval: (refreshInterval) -> @refreshInterval = refreshInterval - - setForceNewTab: (forceNewTab) -> @forceNewTab = forceNewTab - - show: -> - @box.style.display = "block" - @input.focus() - @handlerId = handlerStack.push keydown: @onKeydown.bind @ - - hide: -> - @box.style.display = "none" - @completionList.style.display = "none" - @input.blur() - handlerStack.remove @handlerId - - reset: -> - @input.value = "" - @updateTimer = null - @completions = [] - @selection = @initialSelectionValue - @update(true) - - updateSelection: -> - # We have taken the option to add some global state here (previousCompletionType) to tell if a search - # item has just appeared or disappeared, if that happens we either set the initialSelectionValue to 0 or 1 - # I feel that this approach is cleaner than bubbling the state up from the suggestion level - # so we just inspect it afterwards - if @completions[0] - if @previousCompletionType != "search" && @completions[0].type == "search" - @selection = 0 - else if @previousCompletionType == "search" && @completions[0].type != "search" - @selection = -1 - for i in [0...@completionList.children.length] - @completionList.children[i].className = (if i == @selection then "vomnibarSelected" else "") - @previousCompletionType = @completions[0].type if @completions[0] - - # - # Returns the user's action ("up", "down", "enter", "dismiss" or null) based on their keypress. - # We support the arrow keys and other shortcuts for moving, so this method hides that complexity. - # - actionFromKeyEvent: (event) -> - key = KeyboardUtils.getKeyChar(event) - if (KeyboardUtils.isEscape(event)) - return "dismiss" - else if (key == "up" || - (event.shiftKey && event.keyCode == keyCodes.tab) || - (event.ctrlKey && (key == "k" || key == "p"))) - return "up" - else if (key == "down" || - (event.keyCode == keyCodes.tab && !event.shiftKey) || - (event.ctrlKey && (key == "j" || key == "n"))) - return "down" - else if (event.keyCode == keyCodes.enter) - return "enter" - - onKeydown: (event) -> - action = @actionFromKeyEvent(event) - return true unless action # pass through - - openInNewTab = @forceNewTab || - (event.shiftKey || event.ctrlKey || KeyboardUtils.isPrimaryModifierKey(event)) - if (action == "dismiss") - @hide() - else if (action == "up") - @selection -= 1 - @selection = @completions.length - 1 if @selection < @initialSelectionValue - @input.value = @completions[@selection].url - @updateSelection() - else if (action == "down") - @selection += 1 - @selection = @initialSelectionValue if @selection == @completions.length - @input.value = @completions[@selection].url - @updateSelection() - else if (action == "enter") - # If they type something and hit enter without selecting a completion from our list of suggestions, - # try to open their query as a URL directly. If it doesn't look like a URL, we will search using - # google. - if (@selection == -1) - query = @input.value.trim() - # <Enter> on an empty vomnibar is a no-op. - return unless 0 < query.length - @hide() - chrome.runtime.sendMessage({ - handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" - url: query }) - else - @update true, => - # Shift+Enter will open the result in a new tab instead of the current tab. - @completions[@selection].performAction(openInNewTab) - @hide() - - # It seems like we have to manually suppress the event here and still return true. - DomUtils.suppressPropagation(event) - event.preventDefault() - true - - updateCompletions: (callback) -> - query = @input.value.trim() - - @completer.filter query, (completions) => - @completions = completions - @populateUiWithCompletions(completions) - callback() if callback - - populateUiWithCompletions: (completions) -> - # update completion list with the new data - @completionList.innerHTML = completions.map((completion) -> "<li>#{completion.html}</li>").join("") - @completionList.style.display = if completions.length > 0 then "block" else "none" - @selection = Math.min(Math.max(@initialSelectionValue, @selection), @completions.length - 1) - @updateSelection() - - update: (updateSynchronously, callback) -> - if (updateSynchronously) - # cancel scheduled update - if (@updateTimer != null) - window.clearTimeout(@updateTimer) - @updateCompletions(callback) - else if (@updateTimer != null) - # an update is already scheduled, don't do anything - return - else - # always update asynchronously for better user experience and to take some load off the CPU - # (not every keystroke will cause a dedicated update) - @updateTimer = setTimeout(=> - @updateCompletions(callback) - @updateTimer = null - @refreshInterval) - - initDom: -> - @box = Utils.createElementFromHtml( - """ - <div id="vomnibar" class="vimiumReset"> - <div class="vimiumReset vomnibarSearchArea"> - <input type="text" class="vimiumReset"> - </div> - <ul class="vimiumReset"></ul> - </div> - """) - @box.style.display = "none" - document.body.appendChild(@box) - - @input = document.querySelector("#vomnibar input") - @input.addEventListener "input", => @update() - @completionList = document.querySelector("#vomnibar ul") - @completionList.style.display = "none" - # -# Sends filter and refresh requests to a Vomnibox completer on the background page. +# This wraps the vomnibar iframe, which we inject into the page to provide the vomnibar. # -class BackgroundCompleter - # - name: The background page completer that you want to interface with. Either "omni", "tabs", or - # "bookmarks". */ - constructor: (@name) -> - @filterPort = chrome.runtime.connect({ name: "filterCompleter" }) - - refresh: -> chrome.runtime.sendMessage({ handler: "refreshCompleter", name: @name }) - - filter: (query, callback) -> - id = Utils.createUniqueId() - @filterPort.onMessage.addListener (msg) => - @filterPort.onMessage.removeListener(arguments.callee) - # The result objects coming from the background page will be of the form: - # { html: "", type: "", url: "" } - # type will be one of [tab, bookmark, history, domain]. - results = msg.results.map (result) -> - functionToCall = if (result.type == "tab") - BackgroundCompleter.completionActions.switchToTab.curry(result.tabId) - else - BackgroundCompleter.completionActions.navigateToUrl.curry(result.url) - result.performAction = functionToCall - result - callback(results) - - @filterPort.postMessage({ id: id, name: @name, query: query }) - -extend BackgroundCompleter, - # - # These are the actions we can perform when the user selects a result in the Vomnibox. - # - completionActions: - navigateToUrl: (url, openInNewTab) -> - # If the URL is a bookmarklet prefixed with javascript:, we shouldn't open that in a new tab. - if url.startsWith "javascript:" - script = document.createElement 'script' - script.textContent = decodeURIComponent(url["javascript:".length..]) - (document.head || document.documentElement).appendChild script - else - chrome.runtime.sendMessage( - handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" - url: url, - selected: openInNewTab) - - switchToTab: (tabId) -> chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: tabId }) +Vomnibar = + vomnibarUI: null + + activate: -> @open {completer:"omni"} + activateInNewTab: -> @open { + completer: "omni" + selectFirst: false + newTab: true + } + activateTabSelection: -> @open { + completer: "tabs" + selectFirst: true + } + activateBookmarks: -> @open { + completer: "bookmarks" + selectFirst: true + } + activateBookmarksInNewTab: -> @open { + completer: "bookmarks" + selectFirst: true + newTab: true + } + activateEditUrl: -> @open { + completer: "omni" + selectFirst: false + query: window.location.href + } + activateEditUrlInNewTab: -> @open { + completer: "omni" + selectFirst: false + query: window.location.href + newTab: true + } + + init: -> + unless @vomnibarUI? + @vomnibarUI = new UIComponent "pages/vomnibar.html", "vomnibarFrame", (event) => + @vomnibarUI.hide() if event.data == "hide" + + # This function opens the vomnibar. It accepts options, a map with the values: + # completer - The completer to fetch results from. + # query - Optional. Text to prefill the Vomnibar with. + # selectFirst - Optional, boolean. Whether to select the first entry. + # newTab - Optional, boolean. Whether to open the result in a new tab. + open: (options) -> @vomnibarUI.activate options root = exports ? window root.Vomnibar = Vomnibar diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index a0ac0bd3..ba5e279f 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -33,47 +33,24 @@ DomUtils = makeXPath: (elementArray) -> xpath = [] for element in elementArray - xpath.push("//" + element, "//xhtml:" + element) + xpath.push(".//" + element, ".//xhtml:" + element) xpath.join(" | ") + # Evaluates an XPath on the whole document, or on the contents of the fullscreen element if an element is + # fullscreen. evaluateXPath: (xpath, resultType) -> + contextNode = + if document.webkitIsFullScreen then document.webkitFullscreenElement else document.documentElement namespaceResolver = (namespace) -> if (namespace == "xhtml") then "http://www.w3.org/1999/xhtml" else null - document.evaluate(xpath, document.documentElement, namespaceResolver, resultType, null) + document.evaluate(xpath, contextNode, namespaceResolver, resultType, null) # # Returns the first visible clientRect of an element if it exists. Otherwise it returns null. # getVisibleClientRect: (element) -> # Note: this call will be expensive if we modify the DOM in between calls. - clientRects = ({ - top: clientRect.top, right: clientRect.right, bottom: clientRect.bottom, left: clientRect.left, - width: clientRect.width, height: clientRect.height - } for clientRect in element.getClientRects()) - - for clientRect in clientRects - if (clientRect.top < 0) - clientRect.height += clientRect.top - clientRect.top = 0 - - if (clientRect.left < 0) - clientRect.width += clientRect.left - clientRect.left = 0 - - if (clientRect.top >= window.innerHeight - 4 || clientRect.left >= window.innerWidth - 4) - continue - - if (clientRect.width < 3 || clientRect.height < 3) - continue - - # eliminate invisible elements (see test_harnesses/visibility_test.html) - computedStyle = window.getComputedStyle(element, null) - if (computedStyle.getPropertyValue('visibility') != 'visible' || - computedStyle.getPropertyValue('display') == 'none' || - computedStyle.getPropertyValue('opacity') == '0') - continue - - return clientRect + clientRects = (Rect.copy clientRect for clientRect in element.getClientRects()) for clientRect in clientRects # If the link has zero dimensions, it may be wrapping visible @@ -86,11 +63,72 @@ DomUtils = continue if (computedStyle.getPropertyValue('float') == 'none' && computedStyle.getPropertyValue('position') != 'absolute') childClientRect = @getVisibleClientRect(child) - continue if (childClientRect == null) + continue if childClientRect == null or childClientRect.width < 3 or childClientRect.height < 3 return childClientRect + + else + clientRect = @cropRectToVisible clientRect + + continue if clientRect == null or clientRect.width < 3 or clientRect.height < 3 + + # eliminate invisible elements (see test_harnesses/visibility_test.html) + computedStyle = window.getComputedStyle(element, null) + if (computedStyle.getPropertyValue('visibility') != 'visible' || + computedStyle.getPropertyValue('display') == 'none') + continue + + return clientRect + null # + # Bounds the rect by the current viewport dimensions. If the rect is offscreen or has a height or width < 3 + # then null is returned instead of a rect. + # + cropRectToVisible: (rect) -> + boundedRect = Rect.create( + Math.max(rect.left, 0) + Math.max(rect.top, 0) + rect.right + rect.bottom + ) + if boundedRect.top >= window.innerHeight - 4 or boundedRect.left >= window.innerWidth - 4 + null + else + boundedRect + + # + # Get the client rects for the <area> elements in a <map> based on the position of the <img> element using + # the map. Returns an array of rects. + # + getClientRectsForAreas: (imgClientRect, areas) -> + rects = [] + for area in areas + coords = area.coords.split(",").map((coord) -> parseInt(coord, 10)) + shape = area.shape.toLowerCase() + if shape in ["rect", "rectangle"] # "rectangle" is an IE non-standard. + [x1, y1, x2, y2] = coords + else if shape in ["circle", "circ"] # "circ" is an IE non-standard. + [x, y, r] = coords + diff = r / Math.sqrt 2 # Gives us an inner square + x1 = x - diff + x2 = x + diff + y1 = y - diff + y2 = y + diff + else if shape == "default" + [x1, y1, x2, y2] = [0, 0, imgClientRect.width, imgClientRect.height] + else + # Just consider the rectangle surrounding the first two points in a polygon. It's possible to do + # something more sophisticated, but likely not worth the effort. + [x1, y1, x2, y2] = coords + + rect = Rect.translate (Rect.create x1, y1, x2, y2), imgClientRect.left, imgClientRect.top + rect = @cropRectToVisible rect + + rects.push {element: area, rect: rect} if rect and not isNaN rect.top + rects + + # # 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: diff --git a/lib/rect.coffee b/lib/rect.coffee new file mode 100644 index 00000000..adc1fc36 --- /dev/null +++ b/lib/rect.coffee @@ -0,0 +1,82 @@ +# Commands for manipulating rects. +Rect = + # Create a rect given the top left and bottom right corners. + create: (x1, y1, x2, y2) -> + bottom: y2 + top: y1 + left: x1 + right: x2 + width: x2 - x1 + height: y2 - y1 + + copy: (rect) -> + bottom: rect.bottom + top: rect.top + left: rect.left + right: rect.right + width: rect.width + height: rect.height + + # Translate a rect by x horizontally and y vertically. + translate: (rect, x = 0, y = 0) -> + bottom: rect.bottom + y + top: rect.top + y + left: rect.left + x + right: rect.right + x + width: rect.width + height: rect.height + + # Subtract rect2 from rect1, returning an array of rects which are in rect1 but not rect2. + subtract: (rect1, rect2) -> + # Bound rect2 by rect1 + rect2 = @create( + Math.max(rect1.left, rect2.left), + Math.max(rect1.top, rect2.top), + Math.min(rect1.right, rect2.right), + Math.min(rect1.bottom, rect2.bottom) + ) + + # If bounding rect2 has made the width or height negative, rect1 does not contain rect2. + return [Rect.copy rect1] if rect2.width < 0 or rect2.height < 0 + + # + # All the possible rects, in the order + # +-+-+-+ + # |1|2|3| + # +-+-+-+ + # |4| |5| + # +-+-+-+ + # |6|7|8| + # +-+-+-+ + # where the outer rectangle is rect1 and the inner rectangle is rect 2. Note that the rects may be of + # width or height 0. + # + rects = [ + # Top row. + @create rect1.left, rect1.top, rect2.left, rect2.top + @create rect2.left, rect1.top, rect2.right, rect2.top + @create rect2.right, rect1.top, rect1.right, rect2.top + # Middle row. + @create rect1.left, rect2.top, rect2.left, rect2.bottom + @create rect2.right, rect2.top, rect1.right, rect2.bottom + # Bottom row. + @create rect1.left, rect2.bottom, rect2.left, rect1.bottom + @create rect2.left, rect2.bottom, rect2.right, rect1.bottom + @create rect2.right, rect2.bottom, rect1.right, rect1.bottom + ] + + rects.filter (rect) -> rect.height > 0 and rect.width > 0 + + contains: (rect1, rect2) -> + rect1.right > rect2.left and + rect1.left < rect2.right and + rect1.bottom > rect2.top and + rect1.top < rect2.bottom + + equals: (rect1, rect2) -> + for property in ["top", "bottom", "left", "right", "width", "height"] + return false if rect1[property] != rect2[property] + true + +root = exports ? window +root.Rect = Rect diff --git a/lib/utils.coffee b/lib/utils.coffee index 8f6dca2a..661f7e84 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -26,11 +26,10 @@ Utils = -> id += 1 hasChromePrefix: do -> - chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:" ] + chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:", "javascript:" ] (url) -> - if 0 < url.indexOf ":" - for prefix in chromePrefixes - return true if url.startsWith prefix + for prefix in chromePrefixes + return true if url.startsWith prefix false hasFullUrlPrefix: do -> @@ -137,6 +136,11 @@ Utils = return 1 0 + # True if the current Chrome version is at least the required version. + haveChromeVersion: (required) -> + chromeVersion = navigator.appVersion.match(/Chrome\/(.*?) /)?[1] + chromeVersion and 0 <= Utils.compareVersions chromeVersion, required + # Zip two (or more) arrays: # - Utils.zip([ [a,b], [1,2] ]) returns [ [a,1], [b,2] ] # - Length of result is `arrays[0].length`. diff --git a/manifest.json b/manifest.json index 3cd88d1e..a365f390 100644 --- a/manifest.json +++ b/manifest.json @@ -35,8 +35,10 @@ "js": ["lib/utils.js", "lib/keyboard_utils.js", "lib/dom_utils.js", + "lib/rect.js", "lib/handler_stack.js", "lib/clipboard.js", + "content_scripts/ui_component.js", "content_scripts/link_hints.js", "content_scripts/vomnibar.js", "content_scripts/scroller.js", @@ -57,5 +59,8 @@ "browser_action": { "default_icon": "icons/browser_action_disabled.png", "default_popup": "pages/popup.html" - } + }, + "web_accessible_resources": [ + "pages/vomnibar.html" + ] } diff --git a/pages/options.html b/pages/options.html index ae45537a..d37646c4 100644 --- a/pages/options.html +++ b/pages/options.html @@ -54,11 +54,13 @@ unmapAll <td verticalAlign="top"> <div class="help"> <div class="example"> - 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. + Add search-engine shortcuts to the Vomnibar. Format:<br/> + <pre> +a: http://a.com/?q=%s +b: http://b.com/?q=%s description +" this is a comment +# this is also a comment</pre> + %s is replaced with the search terms. </div> </div> <textarea id="searchEngines"></textarea> diff --git a/pages/ui_component_server.coffee b/pages/ui_component_server.coffee new file mode 100644 index 00000000..8b43095b --- /dev/null +++ b/pages/ui_component_server.coffee @@ -0,0 +1,27 @@ + +# Fetch the Vimium secret, register the port recieved from the parent window, and stop listening for messages +# on the window object. vimiumSecret is accessible only within the current instantion of Vimium. So a +# malicious host page trying to register its own port can do no better than guessing. +registerPort = (event) -> + chrome.storage.local.get "vimiumSecret", ({vimiumSecret: secret}) -> + return unless event.source == window.parent and event.data == secret + UIComponentServer.portOpen event.ports[0] + window.removeEventListener "message", registerPort + +window.addEventListener "message", registerPort + +UIComponentServer = + ownerPagePort: null + handleMessage: null + + portOpen: (@ownerPagePort) -> + @ownerPagePort.onmessage = (event) => + @handleMessage event if @handleMessage + + registerHandler: (@handleMessage) -> + + postMessage: (message) -> + @ownerPagePort.postMessage message if @ownerPagePort + +root = exports ? window +root.UIComponentServer = UIComponentServer diff --git a/pages/vomnibar.coffee b/pages/vomnibar.coffee new file mode 100644 index 00000000..0ade7f0e --- /dev/null +++ b/pages/vomnibar.coffee @@ -0,0 +1,235 @@ +# +# This controls the contents of the Vomnibar iframe. We use an iframe to avoid changing the selection on the +# page (useful for bookmarklets), ensure that the Vomnibar style is unaffected by the page, and simplify key +# handling in vimium_frontend.coffee +# +Vomnibar = + vomnibarUI: null # the dialog instance for this window + getUI: -> @vomnibarUI + completers: {} + + getCompleter: (name) -> + if (!(name of @completers)) + @completers[name] = new BackgroundCompleter(name) + @completers[name] + + # + # Activate the Vomnibox. + # + activate: (userOptions) -> + options = + completer: "omni" + query: "" + newTab: false + selectFirst: false + extend options, userOptions + + options.refreshInterval = switch options.completer + when "omni" then 100 + else 0 + + completer = @getCompleter(options.completer) + @vomnibarUI ?= new VomnibarUI() + completer.refresh() + @vomnibarUI.setInitialSelectionValue(if options.selectFirst then 0 else -1) + @vomnibarUI.setCompleter(completer) + @vomnibarUI.setRefreshInterval(options.refreshInterval) + @vomnibarUI.setForceNewTab(options.newTab) + @vomnibarUI.setQuery(options.query) + @vomnibarUI.update() + +class VomnibarUI + constructor: -> + @refreshInterval = 0 + @initDom() + + setQuery: (query) -> @input.value = query + + setInitialSelectionValue: (initialSelectionValue) -> + @initialSelectionValue = initialSelectionValue + + setCompleter: (completer) -> + @completer = completer + @reset() + @update(true) + + setRefreshInterval: (refreshInterval) -> @refreshInterval = refreshInterval + + setForceNewTab: (forceNewTab) -> @forceNewTab = forceNewTab + + hide: -> + UIComponentServer.postMessage "hide" + @reset() + + reset: -> + @completionList.style.display = "" + @input.value = "" + @updateTimer = null + @completions = [] + @selection = @initialSelectionValue + + updateSelection: -> + # We have taken the option to add some global state here (previousCompletionType) to tell if a search + # item has just appeared or disappeared, if that happens we either set the initialSelectionValue to 0 or 1 + # I feel that this approach is cleaner than bubbling the state up from the suggestion level + # so we just inspect it afterwards + if @completions[0] + if @previousCompletionType != "search" && @completions[0].type == "search" + @selection = 0 + else if @previousCompletionType == "search" && @completions[0].type != "search" + @selection = -1 + for i in [0...@completionList.children.length] + @completionList.children[i].className = (if i == @selection then "vomnibarSelected" else "") + @previousCompletionType = @completions[0].type if @completions[0] + + # + # Returns the user's action ("up", "down", "enter", "dismiss" or null) based on their keypress. + # We support the arrow keys and other shortcuts for moving, so this method hides that complexity. + # + actionFromKeyEvent: (event) -> + key = KeyboardUtils.getKeyChar(event) + if (KeyboardUtils.isEscape(event)) + return "dismiss" + else if (key == "up" || + (event.shiftKey && event.keyCode == keyCodes.tab) || + (event.ctrlKey && (key == "k" || key == "p"))) + return "up" + else if (key == "down" || + (event.keyCode == keyCodes.tab && !event.shiftKey) || + (event.ctrlKey && (key == "j" || key == "n"))) + return "down" + else if (event.keyCode == keyCodes.enter) + return "enter" + + onKeydown: (event) => + action = @actionFromKeyEvent(event) + return true unless action # pass through + + openInNewTab = @forceNewTab || + (event.shiftKey || event.ctrlKey || KeyboardUtils.isPrimaryModifierKey(event)) + if (action == "dismiss") + @hide() + else if (action == "up") + @selection -= 1 + @selection = @completions.length - 1 if @selection < @initialSelectionValue + @updateSelection() + else if (action == "down") + @selection += 1 + @selection = @initialSelectionValue if @selection == @completions.length + @updateSelection() + else if (action == "enter") + # If they type something and hit enter without selecting a completion from our list of suggestions, + # try to open their query as a URL directly. If it doesn't look like a URL, we will search using + # google. + if (@selection == -1) + query = @input.value.trim() + # <Enter> on an empty vomnibar is a no-op. + return unless 0 < query.length + @hide() + chrome.runtime.sendMessage({ + handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" + url: query }) + else + @update true, => + # Shift+Enter will open the result in a new tab instead of the current tab. + @completions[@selection].performAction(openInNewTab) + @hide() + + # It seems like we have to manually suppress the event here and still return true. + event.stopImmediatePropagation() + event.preventDefault() + true + + updateCompletions: (callback) -> + query = @input.value.trim() + + @completer.filter query, (completions) => + @completions = completions + @populateUiWithCompletions(completions) + callback() if callback + + populateUiWithCompletions: (completions) -> + # update completion list with the new data + @completionList.innerHTML = completions.map((completion) -> "<li>#{completion.html}</li>").join("") + @completionList.style.display = if completions.length > 0 then "block" else "" + @selection = Math.min(Math.max(@initialSelectionValue, @selection), @completions.length - 1) + @updateSelection() + + update: (updateSynchronously, callback) => + if (updateSynchronously) + # cancel scheduled update + if (@updateTimer != null) + window.clearTimeout(@updateTimer) + @updateCompletions(callback) + else if (@updateTimer != null) + # an update is already scheduled, don't do anything + return + else + # always update asynchronously for better user experience and to take some load off the CPU + # (not every keystroke will cause a dedicated update) + @updateTimer = setTimeout(=> + @updateCompletions(callback) + @updateTimer = null + @refreshInterval) + + @input.focus() + + initDom: -> + @box = document.getElementById("vomnibar") + + @input = @box.querySelector("input") + @input.addEventListener "input", @update + @input.addEventListener "keydown", @onKeydown + @completionList = @box.querySelector("ul") + @completionList.style.display = "" + + window.addEventListener "focus", => @input.focus() + +# +# Sends filter and refresh requests to a Vomnibox completer on the background page. +# +class BackgroundCompleter + # - name: The background page completer that you want to interface with. Either "omni", "tabs", or + # "bookmarks". */ + constructor: (@name) -> + @filterPort = chrome.runtime.connect({ name: "filterCompleter" }) + + refresh: -> chrome.runtime.sendMessage({ handler: "refreshCompleter", name: @name }) + + filter: (query, callback) -> + id = Utils.createUniqueId() + @filterPort.onMessage.addListener (msg) => + @filterPort.onMessage.removeListener(arguments.callee) + # The result objects coming from the background page will be of the form: + # { html: "", type: "", url: "" } + # type will be one of [tab, bookmark, history, domain]. + results = msg.results.map (result) -> + functionToCall = if (result.type == "tab") + BackgroundCompleter.completionActions.switchToTab.curry(result.tabId) + else + BackgroundCompleter.completionActions.navigateToUrl.curry(result.url) + result.performAction = functionToCall + result + callback(results) + + @filterPort.postMessage({ id: id, name: @name, query: query }) + +extend BackgroundCompleter, + # + # These are the actions we can perform when the user selects a result in the Vomnibox. + # + completionActions: + navigateToUrl: (url, openInNewTab) -> + # If the URL is a bookmarklet prefixed with javascript:, we shouldn't open that in a new tab. + openInNewTab = false if url.startsWith("javascript:") + chrome.runtime.sendMessage( + handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab" + url: url, + selected: openInNewTab) + + switchToTab: (tabId) -> chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: tabId }) + +UIComponentServer.registerHandler (event) -> Vomnibar.activate event.data + +root = exports ? window +root.Vomnibar = Vomnibar diff --git a/pages/vomnibar.css b/pages/vomnibar.css new file mode 100644 index 00000000..2042a6c4 --- /dev/null +++ b/pages/vomnibar.css @@ -0,0 +1,136 @@ + +/* Vomnibar CSS */ + +#vomnibar ol, #vomnibar ul { + list-style: none; + display: none; +} + +#vomnibar { + display: block; + position: fixed; + width: calc(100% - 20px); /* adjusted to keep border radius and box-shadow visible*/ + /*min-width: 400px; + top: 70px; + left: 50%;*/ + top: 8px; + left: 8px; + /*margin: 0 0 0 -40%;*/ + font-family: sans-serif; + + background: #F1F1F1; + text-align: left; + border-radius: 4px; + box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8); + border: 1px solid #aaa; + /* One less than hint markers and the help dialog. */ + z-index: 2147483646; +} + +#vomnibar input { + color: #000; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 20px; + height: 34px; + margin-bottom: 0; + padding: 4px; + background-color: white; + border-radius: 3px; + border: 1px solid #E8E8E8; + box-shadow: #444 0px 0px 1px; + width: 100%; + outline: none; + box-sizing: border-box; +} + +#vomnibar .vomnibarSearchArea { + display: block; + padding: 10px; + background-color: #F1F1F1; + border-radius: 4px 4px 0 0; + border-bottom: 1px solid #C6C9CE; +} + +#vomnibar ul { + background-color: white; + border-radius: 0 0 4px 4px; + list-style: none; + padding: 10px 0; + padding-top: 0; +} + +#vomnibar li { + border-bottom: 1px solid #ddd; + line-height: 1.1em; + padding: 7px 10px; + font-size: 16px; + color: black; + position: relative; + display: list-item; + margin: auto; +} + +#vomnibar li:last-of-type { + border-bottom: none; +} + +#vomnibar li .vomnibarTopHalf, #vomnibar li .vomnibarBottomHalf { + display: block; + overflow: hidden; +} + +#vomnibar li .vomnibarBottomHalf { + font-size: 15px; + margin-top: 3px; + padding: 2px 0; +} + +#vomnibar li .vomnibarIcon { + background-position-y: center; + background-size: 16px; + background-repeat: no-repeat; + padding-left: 20px; +} + +#vomnibar li .vomnibarSource { + color: #777; + margin-right: 4px; +} +#vomnibar li .vomnibarRelevancy { + position: absolute; + right: 0; + top: 0; + padding: 5px; + background-color: white; + color: black; + font-family: monospace; + width: 100px; + overflow: hidden; +} + +#vomnibar li .vomnibarUrl { + white-space: nowrap; + color: #224684; +} + +#vomnibar li .vomnibarMatch { + font-weight: bold; + color: black; +} + +#vomnibar li em, #vomnibar li .vomnibarTitle { + color: black; + margin-left: 4px; + font-weight: normal; +} +#vomnibar li em { font-style: italic; } +#vomnibar li em .vomnibarMatch, #vomnibar li .vomnibarTitle .vomnibarMatch { + color: #333; + text-decoration: underline; +} + +#vomnibar li.vomnibarSelected { + background-color: #BBCEE9; + font-weight: normal; +} + diff --git a/pages/vomnibar.html b/pages/vomnibar.html new file mode 100644 index 00000000..2ca463d0 --- /dev/null +++ b/pages/vomnibar.html @@ -0,0 +1,22 @@ +<html> + <head> + <title>Vomnibar</title> + <script type="text/javascript" src="../lib/utils.js"></script> + <script type="text/javascript" src="../lib/keyboard_utils.js"></script> + <script type="text/javascript" src="../lib/dom_utils.js"></script> + <script type="text/javascript" src="../lib/handler_stack.js"></script> + <script type="text/javascript" src="../lib/clipboard.js"></script> + <script type="text/javascript" src="ui_component_server.js"></script> + <script type="text/javascript" src="vomnibar.js"></script> + <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> + <link rel="stylesheet" type="text/css" href="vomnibar.css" /> + </head> + <body> + <div id="vomnibar" class="vimiumReset"> + <div class="vimiumReset vomnibarSearchArea"> + <input type="text" class="vimiumReset"> + </div> + <ul class="vimiumReset"></ul> + </div> + </body> +</html> diff --git a/test_harnesses/vomnibar.html b/test_harnesses/vomnibar.html index 4d50e749..820210b0 100644 --- a/test_harnesses/vomnibar.html +++ b/test_harnesses/vomnibar.html @@ -11,7 +11,7 @@ <script type="text/javascript" src="../lib/keyboard_utils.js"></script> <script type="text/javascript" src="../lib/dom_utils.js"></script> <script src="https://github.com/ooyala/livecss/raw/master/livecss.js"></script> - <script type="text/javascript" src="../content_scripts/vomnibar.js"></script> + <script type="text/javascript" src="../pages/vomnibar.js"></script> <link rel="stylesheet" type="text/css" href="../vimium.css" /> <script> function setup() { diff --git a/tests/dom_tests/chrome.coffee b/tests/dom_tests/chrome.coffee index 7f99e27f..ad4ae74b 100644 --- a/tests/dom_tests/chrome.coffee +++ b/tests/dom_tests/chrome.coffee @@ -10,6 +10,9 @@ root.chrome = { onMessage: { addListener: -> } + onDisconnect: { + addListener: -> + } postMessage: -> } onMessage: { @@ -17,5 +20,10 @@ root.chrome = { } sendMessage: -> getManifest: -> + getURL: (url) -> "../../#{url}" } + storage: + local: + get: -> + set: -> } diff --git a/tests/dom_tests/dom_tests.html b/tests/dom_tests/dom_tests.html index feddafac..a764b42d 100644 --- a/tests/dom_tests/dom_tests.html +++ b/tests/dom_tests/dom_tests.html @@ -32,8 +32,10 @@ <script type="text/javascript" src="../../lib/utils.js"></script> <script type="text/javascript" src="../../lib/keyboard_utils.js"></script> <script type="text/javascript" src="../../lib/dom_utils.js"></script> + <script type="text/javascript" src="../../lib/rect.js"></script> <script type="text/javascript" src="../../lib/handler_stack.js"></script> <script type="text/javascript" src="../../lib/clipboard.js"></script> + <script type="text/javascript" src="../../content_scripts/ui_component.js"></script> <script type="text/javascript" src="../../content_scripts/link_hints.js"></script> <script type="text/javascript" src="../../content_scripts/vomnibar.js"></script> <script type="text/javascript" src="../../content_scripts/scroller.js"></script> @@ -52,6 +54,5 @@ <h1>Vimium Tests</h1> <div id="output-div"></div> - </body> </html> diff --git a/tests/dom_tests/dom_utils_test.coffee b/tests/dom_tests/dom_utils_test.coffee index 130a3014..ad8bde3c 100644 --- a/tests/dom_tests/dom_utils_test.coffee +++ b/tests/dom_tests/dom_utils_test.coffee @@ -50,12 +50,6 @@ context "Check visibility", assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'foo') != null assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'bar') != null - should "detect opacity:0 links as hidden", -> - document.getElementById("test-div").innerHTML = """ - <a id='foo' style='opacity:0'>test</a> - """ - assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo' - should "detect links that contain only floated / absolutely-positioned divs as visible", -> document.getElementById("test-div").innerHTML = """ <a id='foo'> diff --git a/tests/dom_tests/vomnibar_test.coffee b/tests/dom_tests/vomnibar_test.coffee index b414fdfb..0e02bb7b 100644 --- a/tests/dom_tests/vomnibar_test.coffee +++ b/tests/dom_tests/vomnibar_test.coffee @@ -1,19 +1,34 @@ +vomnibarFrame = null + context "Keep selection within bounds", setup -> @completions = [] - oldGetCompleter = Vomnibar.getCompleter.bind Vomnibar - stub Vomnibar, 'getCompleter', (name) => + + vomnibarFrame = Vomnibar.vomnibarUI.iframeElement.contentWindow + + # The Vomnibar frame is dynamically injected, so inject our stubs here. + vomnibarFrame.Function::bind = Function::bind + vomnibarFrame.chrome = chrome + + oldGetCompleter = vomnibarFrame.Vomnibar.getCompleter.bind vomnibarFrame.Vomnibar + stub vomnibarFrame.Vomnibar, 'getCompleter', (name) => completer = oldGetCompleter name stub completer, 'filter', (query, callback) => callback(@completions) completer + # Shoulda.js doesn't support async tests, so we have to hack around. + stub Vomnibar.vomnibarUI, "postMessage", (data) -> + vomnibarFrame.UIComponentServer.handleMessage {data} + stub vomnibarFrame.UIComponentServer, "postMessage", (data) -> + UIComponent.handleMessage {data} + tearDown -> Vomnibar.vomnibarUI.hide() should "set selection to position -1 for omni completion by default", -> Vomnibar.activate() - ui = Vomnibar.vomnibarUI + ui = vomnibarFrame.Vomnibar.vomnibarUI @completions = [] ui.update(true) @@ -29,7 +44,7 @@ context "Keep selection within bounds", should "set selection to position 0 for bookmark completion if possible", -> Vomnibar.activateBookmarks() - ui = Vomnibar.vomnibarUI + ui = vomnibarFrame.Vomnibar.vomnibarUI @completions = [] ui.update(true) @@ -45,7 +60,7 @@ context "Keep selection within bounds", should "keep selection within bounds", -> Vomnibar.activate() - ui = Vomnibar.vomnibarUI + ui = vomnibarFrame.Vomnibar.vomnibarUI @completions = [] ui.update(true) diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index e4966016..b7b73cc2 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -233,17 +233,24 @@ context "tab completer", context "search engines", setup -> - searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s" + searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s baz description" Settings.set 'searchEngines', searchEngines @completer = new SearchEngineCompleter() # note, I couldn't just call @completer.refresh() here as I couldn't set root.Settings without errors # workaround is below, would be good for someone that understands the testing system better than me to improve @completer.searchEngines = Settings.getSearchEngines() - should "return search engine suggestion", -> + should "return search engine suggestion without description", -> results = filterCompleter(@completer, ["foo", "hello"]) assert.arrayEqual ["bar?q=hello"], results.map (result) -> result.url assert.arrayEqual ["foo: hello"], results.map (result) -> result.title + assert.arrayEqual ["search"], results.map (result) -> result.type + + should "return search engine suggestion with description", -> + results = filterCompleter(@completer, ["baz", "hello"]) + assert.arrayEqual ["qux?q=hello"], results.map (result) -> result.url + assert.arrayEqual ["hello"], results.map (result) -> result.title + assert.arrayEqual ["baz description"], results.map (result) -> result.type context "suggestions", should "escape html in page titles", -> diff --git a/tests/unit_tests/rect_test.coffee b/tests/unit_tests/rect_test.coffee new file mode 100644 index 00000000..cfb26b05 --- /dev/null +++ b/tests/unit_tests/rect_test.coffee @@ -0,0 +1,232 @@ +require "./test_helper.js" +extend(global, require "../../lib/rect.js") + +context "Rect", + should "set rect properties correctly", -> + [x1, y1, x2, y2] = [1, 2, 3, 4] + rect = Rect.create x1, y1, x2, y2 + assert.equal rect.left, x1 + assert.equal rect.top, y1 + assert.equal rect.right, x2 + assert.equal rect.bottom, y2 + assert.equal rect.width, x2 - x1 + assert.equal rect.height, y2 - y1 + + should "translate rect horizontally", -> + [x1, y1, x2, y2] = [1, 2, 3, 4] + x = 5 + rect1 = Rect.create x1, y1, x2, y2 + rect2 = Rect.translate rect1, x + + assert.equal rect1.left + x, rect2.left + assert.equal rect1.right + x, rect2.right + + assert.equal rect1.width, rect2.width + assert.equal rect1.height, rect2.height + assert.equal rect1.top, rect2.top + assert.equal rect1.bottom, rect2.bottom + + should "translate rect vertically", -> + [x1, y1, x2, y2] = [1, 2, 3, 4] + y = 5 + rect1 = Rect.create x1, y1, x2, y2 + rect2 = Rect.translate rect1, undefined, y + + assert.equal rect1.top + y, rect2.top + assert.equal rect1.bottom + y, rect2.bottom + + assert.equal rect1.width, rect2.width + assert.equal rect1.height, rect2.height + assert.equal rect1.left, rect2.left + assert.equal rect1.right, rect2.right + +context "Rect subtraction", + context "unchanged by rects outside", + should "left, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -2, -2, -1, -1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -2, 0, -1, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -2, 2, -1, 3 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 2, -2, 3, -1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 2, 0, 3, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 2, 2, 3, 3 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, -2, 1, -1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, 2, 1, 3 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + context "unchanged by rects touching", + should "left, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -1, -1, 0, 0 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -1, 0, 0, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "left, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create -1, 1, 0, 2 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 1, -1, 2, 0 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 1, 0, 2, 1 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "right, below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 1, 1, 2, 2 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "above", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, -1, 1, 0 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "below", -> + rect1 = Rect.create 0, 0, 1, 1 + rect2 = Rect.create 0, 1, 1, 2 + + rects = Rect.subtract rect1, rect2 + assert.equal rects.length, 1 + rect = rects[0] + assert.isTrue Rect.equals rect1, rect + + should "have nothing when subtracting itself", -> + rect = Rect.create 0, 0, 1, 1 + rects = Rect.subtract rect, rect + assert.equal rects.length, 0 + + should "not overlap subtracted rect", -> + rect = Rect.create 0, 0, 3, 3 + for x in [-2..2] + for y in [-2..2] + for width in [1..3] + for height in [1..3] + subtractRect = Rect.create x, y, (x + width), (y + height) + resultRects = Rect.subtract rect, subtractRect + for resultRect in resultRects + assert.isFalse Rect.contains subtractRect, resultRect + + should "be contained in original rect", -> + rect = Rect.create 0, 0, 3, 3 + for x in [-2..2] + for y in [-2..2] + for width in [1..3] + for height in [1..3] + subtractRect = Rect.create x, y, (x + width), (y + height) + resultRects = Rect.subtract rect, subtractRect + for resultRect in resultRects + assert.isTrue Rect.contains rect, resultRect + + should "contain the subtracted rect in the original minus the results", -> + rect = Rect.create 0, 0, 3, 3 + for x in [-2..2] + for y in [-2..2] + for width in [1..3] + for height in [1..3] + subtractRect = Rect.create x, y, (x + width), (y + height) + resultRects = Rect.subtract rect, subtractRect + resultComplement = [Rect.copy rect] + for resultRect in resultRects + resultComplement = Array::concat.apply [], + (resultComplement.map (rect) -> Rect.subtract rect, resultRect) + assert.isTrue (resultComplement.length == 0 or resultComplement.length == 1) + if resultComplement.length == 1 + complementRect = resultComplement[0] + assert.isTrue Rect.contains subtractRect, complementRect diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee index 4625457b..afe862a4 100644 --- a/tests/unit_tests/settings_test.coffee +++ b/tests/unit_tests/settings_test.coffee @@ -70,14 +70,15 @@ context "settings", chrome.storage.sync.set { scrollStepSize: JSON.stringify(message) } assert.equal message, Sync.message - should "set search engines, retrieve them correctly and check that it has been parsed correctly", -> - searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s" - parsedSearchEngines = {"foo": "bar?q=%s", "baz": "qux?q=%s"} + should "set search engines, retrieve them correctly and check that they have been parsed correctly", -> + searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s baz description" Settings.set 'searchEngines', searchEngines - assert.equal(searchEngines, Settings.get('searchEngines')) result = Settings.getSearchEngines() - assert.isTrue(parsedSearchEngines["foo"] == result["foo"] && - parsedSearchEngines["baz"] == result["baz"] && Object.keys(result).length == 2) + assert.equal Object.keys(result).length, 2 + assert.equal "bar?q=%s", result["foo"].url + assert.isFalse result["foo"].description + assert.equal "qux?q=%s", result["baz"].url + assert.equal "baz description", result["baz"].description should "sync a key which is not a known setting (without crashing)", -> chrome.storage.sync.set { notASetting: JSON.stringify("notAUsefullValue") } diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index 80750337..3258bcd6 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -42,6 +42,10 @@ exports.chrome = getAll: () -> true storage: + # chrome.storage.local + local: + set: -> + # chrome.storage.onChanged onChanged: addListener: (func) -> @func = func |
