diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/dom_utils.coffee | 94 | ||||
| -rw-r--r-- | lib/handler_stack.coffee | 6 | ||||
| -rw-r--r-- | lib/settings.coffee | 202 | ||||
| -rw-r--r-- | lib/utils.coffee | 192 |
4 files changed, 462 insertions, 32 deletions
diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index 2ae9412e..7c47179c 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -27,6 +27,12 @@ DomUtils = removeElement: (el) -> el.parentNode.removeChild el # + # Test whether the current frame is the top/main frame. + # + isTopFrame: -> + window.top == window.self + + # # Takes an array of XPath selectors, adds the necessary namespaces (currently only XHTML), and applies them # to the document root. The namespaceResolver in evaluateXPath should be kept in sync with the namespaces # here. @@ -49,21 +55,37 @@ DomUtils = # # Returns the first visible clientRect of an element if it exists. Otherwise it returns null. # - getVisibleClientRect: (element) -> + # WARNING: If testChildren = true then the rects of visible (eg. floated) children may be returned instead. + # This is used for LinkHints and focusInput, **BUT IS UNSUITABLE FOR MOST OTHER PURPOSES**. + # + getVisibleClientRect: (element, testChildren = false) -> # Note: this call will be expensive if we modify the DOM in between calls. clientRects = (Rect.copy clientRect for clientRect in element.getClientRects()) + # Inline elements with font-size: 0px; will declare a height of zero, even if a child with non-zero + # font-size contains text. + isInlineZeroHeight = -> + elementComputedStyle = window.getComputedStyle element, null + isInlineZeroFontSize = (0 == elementComputedStyle.getPropertyValue("display").indexOf "inline") and + (elementComputedStyle.getPropertyValue("font-size") == "0px") + # Override the function to return this value for the rest of this context. + isInlineZeroHeight = -> isInlineZeroFontSize + isInlineZeroFontSize + for clientRect in clientRects - # If the link has zero dimensions, it may be wrapping visible - # but floated elements. Check for this. - if (clientRect.width == 0 || clientRect.height == 0) + # If the link has zero dimensions, it may be wrapping visible but floated elements. Check for this. + if (clientRect.width == 0 or clientRect.height == 0) and testChildren for child in element.children computedStyle = window.getComputedStyle(child, null) - # Ignore child elements which are not floated and not absolutely positioned for parent elements with - # zero width/height - continue if (computedStyle.getPropertyValue('float') == 'none' && - computedStyle.getPropertyValue('position') != 'absolute') - childClientRect = @getVisibleClientRect(child) + # Ignore child elements which are not floated and not absolutely positioned for parent elements + # with zero width/height, as long as the case described at isInlineZeroHeight does not apply. + # NOTE(mrmr1993): This ignores floated/absolutely positioned descendants nested within inline + # children. + continue if (computedStyle.getPropertyValue("float") == "none" and + computedStyle.getPropertyValue("position") != "absolute" and + not (clientRect.height == 0 and isInlineZeroHeight() and + 0 == computedStyle.getPropertyValue("display").indexOf "inline")) + childClientRect = @getVisibleClientRect child, true continue if childClientRect == null or childClientRect.width < 3 or childClientRect.height < 3 return childClientRect @@ -74,9 +96,7 @@ DomUtils = # eliminate invisible elements (see test_harnesses/visibility_test.html) computedStyle = window.getComputedStyle(element, null) - if (computedStyle.getPropertyValue('visibility') != 'visible' || - computedStyle.getPropertyValue('display') == 'none') - continue + continue if computedStyle.getPropertyValue('visibility') != 'visible' return clientRect @@ -167,15 +187,20 @@ DomUtils = node = node.parentNode false - # True if element contains the active selection range. + # True if element is editable and contains the active selection range. isSelected: (element) -> + selection = document.getSelection() if element.isContentEditable - node = document.getSelection()?.anchorNode + node = selection.anchorNode node and @isDOMDescendant element, node else - # Note. This makes the wrong decision if the user has placed the caret at the start of element. We - # cannot distinguish that case from the user having made no selection. - element.selectionStart? and element.selectionEnd? and element.selectionEnd != 0 + if selection.type == "Range" and selection.isCollapsed + # The selection is inside the Shadow DOM of a node. We can check the node it registers as being + # before, since this represents the node whose Shadow DOM it's inside. + containerNode = selection.anchorNode.childNodes[selection.anchorOffset] + element == containerNode # True if the selection is inside the Shadow DOM of our element. + else + false simulateSelect: (element) -> # If element is already active, then we don't move the selection. However, we also won't get a new focus @@ -185,11 +210,17 @@ DomUtils = handlerStack.bubbleEvent "click", target: element else element.focus() - unless @isSelected element - # When focusing a textbox (without an existing selection), put the selection caret at the end of the - # textbox's contents. 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) + # If the cursor is at the start of the element's contents, send it to the end. Motivation: + # * the end is a more useful place to focus than the start, + # * this way preserves the last used position (except when it's at the beginning), so the user can + # 'resume where they left off'. + # NOTE(mrmr1993): Some elements throw an error when we try to access their selection properties, so + # wrap this with a try. + try + if element.selectionStart == 0 and element.selectionEnd == 0 + element.setSelectionRange element.value.length, element.value.length + + simulateClick: (element, modifiers) -> modifiers ||= {} @@ -295,5 +326,24 @@ DomUtils = document.body.removeChild div coordinates + # Get the text content of an element (and its descendents), but omit the text content of previously-visited + # nodes. See #1514. + # NOTE(smblott). This is currently O(N^2) (when called on N elements). An alternative would be to mark + # each node visited, and then clear the marks when we're done. + textContent: do -> + visitedNodes = null + reset: -> visitedNodes = [] + get: (element) -> + nodes = document.createTreeWalker element, NodeFilter.SHOW_TEXT + texts = + while node = nodes.nextNode() + continue unless node.nodeType == 3 + continue if node in visitedNodes + text = node.data.trim() + continue unless 0 < text.length + visitedNodes.push node + text + texts.join " " + root = exports ? window root.DomUtils = DomUtils diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee index b0fefc7d..b09d3183 100644 --- a/lib/handler_stack.coffee +++ b/lib/handler_stack.coffee @@ -84,10 +84,8 @@ class HandlerStack # Debugging. logResult: (eventNumber, type, event, handler, result) -> - # FIXME(smblott). Badge updating is too noisy, so we filter it out. However, we do need to look at how - # many badge update events are happening. It seems to be more than necessary. We also filter out - # registerKeyQueue as unnecessarily noisy and not particularly helpful. - return if type in [ "updateBadge", "registerKeyQueue" ] + # Key queue events aren't usually useful for debugging, so we filter them out. + return if type in [ "registerKeyQueue" ] label = switch result when @stopBubblingAndTrue then "stop/true" diff --git a/lib/settings.coffee b/lib/settings.coffee new file mode 100644 index 00000000..dd667dbd --- /dev/null +++ b/lib/settings.coffee @@ -0,0 +1,202 @@ +# +# * Sync.set() and Sync.clear() propagate local changes to chrome.storage.sync. +# * Sync.handleStorageUpdate() listens for changes to chrome.storage.sync and propagates those +# changes to localStorage and into vimium's internal state. +# * Sync.fetchAsync() polls chrome.storage.sync at startup, similarly propagating +# changes to localStorage and into vimium's internal state. +# +# The effect is best-effort synchronization of vimium options/settings between +# chrome/vimium instances. +# +# NOTE: +# Values handled within this module are ALWAYS already JSON.stringifed, so +# they're always non-empty strings. +# + +root = exports ? window +Sync = + + storage: chrome.storage.sync + doNotSync: ["settingsVersion", "previousVersion"] + + # This is called in main.coffee. + init: -> + chrome.storage.onChanged.addListener (changes, area) -> Sync.handleStorageUpdate changes, area + @fetchAsync() + + # Asynchronous fetch from synced storage, called only at startup. + fetchAsync: -> + @storage.get null, (items) => + unless chrome.runtime.lastError + for own key, value of items + Settings.storeAndPropagate key, value if @shouldSyncKey key + + # Asynchronous message from synced storage. + handleStorageUpdate: (changes, area) -> + for own key, change of changes + Settings.storeAndPropagate key, change?.newValue if @shouldSyncKey key + + # Only called synchronously from within vimium, never on a callback. + # No need to propagate updates to the rest of vimium, that's already been done. + set: (key, value) -> + if @shouldSyncKey key + setting = {}; setting[key] = value + @storage.set setting + + # Only called synchronously from within vimium, never on a callback. + clear: (key) -> + @storage.remove key if @shouldSyncKey key + + # Should we synchronize this key? + shouldSyncKey: (key) -> key not in @doNotSync + +# +# Used by all parts of Vimium to manipulate localStorage. +# + +# Select the object to use as the cache for settings. +if Utils.isExtensionPage() + if Utils.isBackgroundPage() + settingsCache = localStorage + else + settingsCache = extend {}, localStorage # Make a copy of the cached settings from localStorage +else + settingsCache = {} + +root.Settings = Settings = + cache: settingsCache + init: -> Sync.init() + get: (key) -> + if (key of @cache) then JSON.parse(@cache[key]) else @defaults[key] + + set: (key, value) -> + # Don't store the value if it is equal to the default, so we can change the defaults in the future + if (value == @defaults[key]) + @clear(key) + else + jsonValue = JSON.stringify value + @cache[key] = jsonValue + Sync.set key, jsonValue + + clear: (key) -> + if @has key + delete @cache[key] + Sync.clear key + + has: (key) -> key of @cache + + # For settings which require action when their value changes, add hooks to this object, to be called from + # options/options.coffee (when the options page is saved), and by Settings.storeAndPropagate (when an + # update propagates from chrome.storage.sync). + postUpdateHooks: {} + + # postUpdateHooks convenience wrapper + performPostUpdateHook: (key, value) -> + @postUpdateHooks[key]? value + + # Only ever called from asynchronous synced-storage callbacks (fetchAsync and handleStorageUpdate). + storeAndPropagate: (key, value) -> + return unless key of @defaults + return if value and key of @cache and @cache[key] is value + defaultValue = @defaults[key] + defaultValueJSON = JSON.stringify(defaultValue) + + if value and value != defaultValueJSON + # Key/value has been changed to non-default value at remote instance. + @cache[key] = value + @performPostUpdateHook key, JSON.parse(value) + else + # Key has been reset to default value at remote instance. + if key of @cache + delete @cache[key] + @performPostUpdateHook key, defaultValue + + # options.coffee and options.html only handle booleans and strings; therefore all defaults must be booleans + # or strings + defaults: + scrollStepSize: 60 + smoothScroll: true + keyMappings: "# Insert your preferred key mappings here." + linkHintCharacters: "sadfjklewcmpgh" + linkHintNumbers: "0123456789" + filterLinkHints: false + hideHud: false + userDefinedLinkHintCss: + """ + div > .vimiumHintMarker { + /* linkhint boxes */ + background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785), + color-stop(100%,#FFC542)); + border: 1px solid #E3BE23; + } + + div > .vimiumHintMarker span { + /* linkhint text */ + color: black; + font-weight: bold; + font-size: 12px; + } + + div > .vimiumHintMarker > .matchingCharacter { + } + """ + # Default exclusion rules. + exclusionRules: + [ + # Disable Vimium on Gmail. + { pattern: "http*://mail.google.com/*", passKeys: "" } + ] + + # NOTE: If a page contains both a single angle-bracket link and a double angle-bracket link, then in + # most cases the single bracket link will be "prev/next page" and the double bracket link will be + # "first/last page", so we put the single bracket first in the pattern string so that it gets searched + # for first. + + # "\bprev\b,\bprevious\b,\bback\b,<,←,«,≪,<<" + previousPatterns: "prev,previous,back,<,\u2190,\xab,\u226a,<<" + # "\bnext\b,\bmore\b,>,→,»,≫,>>" + nextPatterns: "next,more,>,\u2192,\xbb,\u226b,>>" + # default/fall back search engine + searchUrl: "https://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 Wikipedia" + "" + "# More examples." + "#" + "# (Vimium has built-in completion for these.)" + "#" + "# g: http://www.google.com/search?q=%s Google" + "# l: http://www.google.com/search?q=%s&btnI I'm feeling lucky..." + "# y: http://www.youtube.com/results?search_query=%s Youtube" + "# b: https://www.bing.com/search?q=%s Bing" + "# d: https://duckduckgo.com/?q=%s DuckDuckGo" + "# az: http://www.amazon.com/s/?field-keywords=%s Amazon" + "#" + "# Another example (for Vimium does not have completion)." + "#" + "# m: https://www.google.com/maps/search/%s Google Maps" + ].join "\n" + newTabUrl: "chrome://newtab" + grabBackFocus: false + + settingsVersion: Utils.getCurrentVersion() + +# Export Sync via Settings for tests. +root.Settings.Sync = Sync + +# Perform migration from old settings versions, if this is the background page. +if Utils.isBackgroundPage() + + # We use settingsVersion to coordinate any necessary schema changes. + if Utils.compareVersions("1.42", Settings.get("settingsVersion")) != -1 + Settings.set("scrollStepSize", parseFloat Settings.get("scrollStepSize")) + Settings.set("settingsVersion", Utils.getCurrentVersion()) + + # Migration (after 1.49, 2015/2/1). + # Legacy setting: findModeRawQuery (a string). + # New setting: findModeRawQueryList (a list of strings), now stored in chrome.storage.local (not localStorage). + chrome.storage.local.get "findModeRawQueryList", (items) -> + unless chrome.runtime.lastError or items.findModeRawQueryList + rawQuery = Settings.get "findModeRawQuery" + chrome.storage.local.set findModeRawQueryList: (if rawQuery then [ rawQuery ] else []) diff --git a/lib/utils.coffee b/lib/utils.coffee index 64c87842..93045f32 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -2,6 +2,13 @@ Utils = getCurrentVersion: -> chrome.runtime.getManifest().version + # Returns true whenever the current page is from the extension's origin (and thus can access the + # extension's localStorage). + isExtensionPage: -> document.location?.origin + "/" == chrome.extension.getURL "" + + # Returns true whenever the current page is the extension's background page. + isBackgroundPage: -> @isExtensionPage() and chrome.extension.getBackgroundPage() == window + # Takes a dot-notation object string and call the function # that it points to with the correct value for 'this'. invokeCommandString: (str, argArray) -> @@ -26,16 +33,30 @@ Utils = -> id += 1 hasChromePrefix: do -> - chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:", "javascript:" ] + chromePrefixes = [ "about:", "view-source:", "extension:", "chrome-extension:", "data:" ] (url) -> for prefix in chromePrefixes return true if url.startsWith prefix false + hasJavascriptPrefix: (url) -> + url.startsWith "javascript:" + hasFullUrlPrefix: do -> urlPrefix = new RegExp "^[a-z]{3,}://." (url) -> urlPrefix.test url + # Decode valid escape sequences in a URI. This is intended to mimic the best-effort decoding + # Chrome itself seems to apply when a Javascript URI is enetered into the omnibox (or clicked). + # See https://code.google.com/p/chromium/issues/detail?id=483000, #1611 and #1636. + decodeURIByParts: (uri) -> + uri.split(/(?=%)/).map((uriComponent) -> + try + decodeURIComponent uriComponent + catch + uriComponent + ).join "" + # Completes a partial URL (without scheme) createFullUrl: (partialUrl) -> if @hasFullUrlPrefix(partialUrl) then partialUrl else ("http://" + partialUrl) @@ -93,11 +114,32 @@ Utils = query = query.split(/\s+/) if typeof(query) == "string" query.map(encodeURIComponent).join "+" - # Creates a search URL from the given :query. - createSearchUrl: (query) -> - # It would be better to pull the default search engine from chrome itself. However, unfortunately chrome - # does not provide an API for doing so. - Settings.get("searchUrl") + @createSearchQuery query + # Create a search URL from the given :query (using either the provided search URL, or the default one). + # It would be better to pull the default search engine from chrome itself. However, chrome does not provide + # an API for doing so. + createSearchUrl: (query, searchUrl = Settings.get("searchUrl")) -> + searchUrl += "%s" unless 0 <= searchUrl.indexOf "%s" + searchUrl.replace /%s/g, @createSearchQuery query + + # Extract a query from url if it appears to be a URL created from the given search URL. + # For example, map "https://www.google.ie/search?q=star+wars&foo&bar" to "star wars". + extractQuery: do => + queryTerminator = new RegExp "[?&#/]" + httpProtocolRegexp = new RegExp "^https?://" + (searchUrl, url) -> + url = url.replace httpProtocolRegexp + searchUrl = searchUrl.replace httpProtocolRegexp + [ searchUrl, suffixTerms... ] = searchUrl.split "%s" + # We require the URL to start with the search URL. + return null unless url.startsWith searchUrl + # We require any remaining terms in the search URL to also be present in the URL. + for suffix in suffixTerms + return null unless 0 <= url.indexOf suffix + # We use try/catch because decodeURIComponent can throw an exception. + try + url[searchUrl.length..].split(queryTerminator)[0].split("+").map(decodeURIComponent).join " " + catch + null # Converts :string into a Google search if it's not already a URL. We don't bother with escaping characters # as Chrome will do that for us. @@ -107,6 +149,8 @@ Utils = # Special-case about:[url], view-source:[url] and the like if Utils.hasChromePrefix string string + else if Utils.hasJavascriptPrefix string + Utils.decodeURIByParts string else if Utils.isUrl string Utils.createFullUrl string else @@ -169,6 +213,61 @@ Utils = delete obj[property] for property in properties obj + # Does string match any of these regexps? + matchesAnyRegexp: (regexps, string) -> + for re in regexps + return true if re.test string + false + + # Calculate the length of the longest shared prefix of a list of strings. + longestCommonPrefix: (strings) -> + return 0 unless 0 < strings.length + strings.sort (a,b) -> a.length - b.length + [ shortest, strings... ] = strings + for ch, index in shortest.split "" + for str in strings + return index if ch != str[index] + return shortest.length + + # Convenience wrapper for setTimeout (with the arguments around the other way). + setTimeout: (ms, func) -> setTimeout func, ms + + # Like Nodejs's nextTick. + nextTick: (func) -> @setTimeout 0, func + +# Utility for parsing and using the custom search-engine configuration. We re-use the previous parse if the +# search-engine configuration is unchanged. +SearchEngines = + previousSearchEngines: null + searchEngines: null + + refresh: (searchEngines) -> + unless @previousSearchEngines? and searchEngines == @previousSearchEngines + @previousSearchEngines = searchEngines + @searchEngines = new AsyncDataFetcher (callback) -> + engines = {} + for line in searchEngines.split "\n" + line = line.trim() + continue if /^[#"]/.test line + tokens = line.split /\s+/ + continue unless 2 <= tokens.length + keyword = tokens[0].split(":")[0] + searchUrl = tokens[1] + description = tokens[2..].join(" ") || "search (#{keyword})" + continue unless Utils.hasFullUrlPrefix searchUrl + engines[keyword] = { keyword, searchUrl, description } + + callback engines + + # Use the parsed search-engine configuration, possibly asynchronously. + use: (callback) -> + @searchEngines.use callback + + # Both set (refresh) the search-engine configuration and use it at the same time. + refreshAndUse: (searchEngines, callback) -> + @refresh searchEngines + @use callback + # This creates a new function out of an existing function, where the new function takes fewer arguments. This # allows us to pass around functions instead of functions + a partial list of arguments. Function::curry = -> @@ -179,6 +278,8 @@ Function::curry = -> Array.copy = (array) -> Array.prototype.slice.call(array, 0) String::startsWith = (str) -> @indexOf(str) == 0 +String::ltrim = -> @replace /^\s+/, "" +String::rtrim = -> @replace /\s+$/, "" globalRoot = window ? global globalRoot.extend = (hash1, hash2) -> @@ -186,5 +287,84 @@ globalRoot.extend = (hash1, hash2) -> hash1[key] = hash2[key] hash1 +# A simple cache. Entries used within two expiry periods are retained, otherwise they are discarded. +# At most 2 * @entries entries are retained. +class SimpleCache + # expiry: expiry time in milliseconds (default, one hour) + # entries: maximum number of entries in @cache (there may be up to this many entries in @previous, too) + constructor: (@expiry = 60 * 60 * 1000, @entries = 1000) -> + @cache = {} + @previous = {} + @lastRotation = new Date() + + has: (key) -> + @rotate() + (key of @cache) or key of @previous + + # Set value, and return that value. If value is null, then delete key. + set: (key, value = null) -> + @rotate() + delete @previous[key] + if value? + @cache[key] = value + else + delete @cache[key] + null + + get: (key) -> + @rotate() + if key of @cache + @cache[key] + else if key of @previous + @cache[key] = @previous[key] + delete @previous[key] + @cache[key] + else + null + + rotate: (force = false) -> + if force or @entries < Object.keys(@cache).length or @expiry < new Date() - @lastRotation + @lastRotation = new Date() + @previous = @cache + @cache = {} + + clear: -> + @rotate true + @rotate true + +# This is a simple class for the common case where we want to use some data value which may be immediately +# available, or for which we may have to wait. It implements a use-immediately-or-wait queue, and calls the +# fetch function to fetch the data asynchronously. +class AsyncDataFetcher + constructor: (fetch) -> + @data = null + @queue = [] + Utils.nextTick => + fetch (@data) => + callback @data for callback in @queue + @queue = null + + use: (callback) -> + if @data? then callback @data else @queue.push callback + +# This takes a list of jobs (functions) and runs them, asynchronously. Functions queued with @onReady() are +# run once all of the jobs have completed. +class JobRunner + constructor: (@jobs) -> + @fetcher = new AsyncDataFetcher (callback) => + for job in @jobs + do (job) => + Utils.nextTick => + job => + @jobs = @jobs.filter (j) -> j != job + callback true if @jobs.length == 0 + + onReady: (callback) -> + @fetcher.use callback + root = exports ? window root.Utils = Utils +root.SearchEngines = SearchEngines +root.SimpleCache = SimpleCache +root.AsyncDataFetcher = AsyncDataFetcher +root.JobRunner = JobRunner |
