diff options
| -rw-r--r-- | CREDITS | 1 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | background_scripts/commands.coffee | 4 | ||||
| -rw-r--r-- | background_scripts/completion.coffee | 26 | ||||
| -rw-r--r-- | background_scripts/main.coffee | 160 | ||||
| -rw-r--r-- | background_scripts/settings.coffee | 23 | ||||
| -rw-r--r-- | content_scripts/link_hints.coffee | 6 | ||||
| -rw-r--r-- | content_scripts/mode.coffee | 10 | ||||
| -rw-r--r-- | content_scripts/mode_visual_edit.coffee | 6 | ||||
| -rw-r--r-- | content_scripts/scroller.coffee | 3 | ||||
| -rw-r--r-- | content_scripts/ui_component.coffee | 15 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 88 | ||||
| -rw-r--r-- | content_scripts/vomnibar.coffee | 5 | ||||
| -rw-r--r-- | lib/dom_utils.coffee | 50 | ||||
| -rw-r--r-- | manifest.json | 1 | ||||
| -rw-r--r-- | pages/help_dialog.html | 1 | ||||
| -rw-r--r-- | pages/options.coffee | 10 | ||||
| -rw-r--r-- | pages/options.html | 2 | ||||
| -rw-r--r-- | pages/vomnibar.coffee | 41 | ||||
| -rw-r--r-- | tests/dom_tests/dom_tests.coffee | 47 | ||||
| -rw-r--r-- | tests/unit_tests/completion_test.coffee | 8 | ||||
| -rw-r--r-- | tests/unit_tests/settings_test.coffee | 2 | ||||
| -rw-r--r-- | tests/unit_tests/test_chrome_stubs.coffee | 2 |
23 files changed, 317 insertions, 196 deletions
@@ -42,5 +42,6 @@ Contributors: Werner Laurensse (github: ab3) Timo Sand <timo.j.sand@gmail.com> (github: deiga) Shiyong Chen <billbill290@gmail.com> (github: UncleBill) + Utkarsh Upadhyay <musically.ut@gmail.com) (github: musically-ut) Feel free to add real names in addition to GitHub usernames. @@ -174,7 +174,7 @@ Release Notes - Added `gU`, which goes to the root of the current URL. - Added `yt`, which duplicates the current tab. - Added `W`, which moves the current tab to a new window. -- Added marks for saving and jumping to sections of a page. `mX` to set a mark and `X` to return to it. +- Added marks for saving and jumping to sections of a page. `mX` to set a mark and `` `X`` to return to it. - Added "LinkHints.activateModeToOpenIncognito", currently an advanced, unbound command. - Disallowed repeat tab closings, since this causes trouble for many people. - Update our Chrome APIs so Vimium works on Chrome 28+. diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index e371acf3..9aa90c45 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -289,8 +289,8 @@ commandDescriptions = openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true, repeatLimit: 20 }] enterInsertMode: ["Enter insert mode", { noRepeat: true }] - enterVisualMode: ["Enter visual mode", { noRepeat: true }] - enterVisualLineMode: ["Enter visual line mode", { noRepeat: true }] + enterVisualMode: ["Enter visual mode (beta feature)", { noRepeat: true }] + enterVisualLineMode: ["Enter visual line mode (beta feature)", { noRepeat: true }] # enterEditMode: ["Enter vim-like edit mode (not yet implemented)", { noRepeat: true }] focusInput: ["Focus the first text box on the page. Cycle between them using tab", diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index 177892fb..6a1c0d30 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -204,7 +204,7 @@ class DomainCompleter domains: null filter: (queryTerms, onComplete) -> - return onComplete([]) if queryTerms.length > 1 + return onComplete([]) unless queryTerms.length == 1 if @domains @performSearch(queryTerms, onComplete) else @@ -344,11 +344,33 @@ class SearchEngineCompleter computeRelevancy: -> 1 refresh: -> - this.searchEngines = root.Settings.getSearchEngines() + @searchEngines = SearchEngineCompleter.getSearchEngines() getSearchEngineMatches: (queryTerms) -> (1 < queryTerms.length and @searchEngines[queryTerms[0]]) or {} + # Static data and methods for parsing the configured search engines. We keep a cache of the search-engine + # mapping in @searchEnginesMap. + @searchEnginesMap: null + + # Parse the custom search engines setting and cache it in SearchEngineCompleter.searchEnginesMap. + @parseSearchEngines: (searchEnginesText) -> + searchEnginesMap = SearchEngineCompleter.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: -> + unless SearchEngineCompleter.searchEnginesMap? + SearchEngineCompleter.parseSearchEngines Settings.get "searchEngines" + SearchEngineCompleter.searchEnginesMap + # 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. class MultiCompleter diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index 9eafc2a2..72fe1092 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -1,7 +1,24 @@ root = exports ? window -currentVersion = Utils.getCurrentVersion() +# The browser may have tabs already open. We inject the content scripts immediately so that they work straight +# away. +chrome.runtime.onInstalled.addListener ({ reason }) -> + # See https://developer.chrome.com/extensions/runtime#event-onInstalled + return if reason in [ "chrome_update", "shared_module_update" ] + manifest = chrome.runtime.getManifest() + # Content scripts loaded on every page should be in the same group. We assume it is the first. + contentScripts = manifest.content_scripts[0] + jobs = [ [ chrome.tabs.executeScript, contentScripts.js ], [ chrome.tabs.insertCSS, contentScripts.css ] ] + # Chrome complains if we don't evaluate chrome.runtime.lastError on errors (and we get errors for tabs on + # which Vimium cannot run). + checkLastRuntimeError = -> chrome.runtime.lastError + chrome.tabs.query { status: "complete" }, (tabs) -> + for tab in tabs + for [ func, files ] in jobs + for file in files + func tab.id, { file: file, allFrames: contentScripts.allFrames }, checkLastRuntimeError +currentVersion = Utils.getCurrentVersion() tabQueue = {} # windowId -> Array tabInfoMap = {} # tabId -> object with various tab properties keyQueue = "" # Queue of keys typed @@ -9,6 +26,7 @@ validFirstKeys = {} singleKeyCommands = [] focusedFrame = null frameIdsForTab = {} +root.urlForTab = {} # Keys are either literal characters, or "named" - for example <a-b> (alt+b), <left> (left arrow) or <f12> # This regular expression captures two groups: the first is a named key, the second is the remainder of @@ -40,7 +58,7 @@ completers = bookmarks: new MultiCompleter([completionSources.bookmarks]) tabs: new MultiCompleter([completionSources.tabs]) -chrome.runtime.onConnect.addListener((port, name) -> +chrome.runtime.onConnect.addListener (port, name) -> senderTabId = if port.sender.tab then port.sender.tab.id else null # If this is a tab we've been waiting to open, execute any "tab loaded" handlers, e.g. to restore # the tab's scroll position. Wait until domReady before doing this; otherwise operations like restoring @@ -52,14 +70,8 @@ chrome.runtime.onConnect.addListener((port, name) -> delete tabLoadedHandlers[senderTabId] toCall.call() - # domReady is the appropriate time to show the "vimium has been upgraded" message. - # TODO: This might be broken on pages with frames. - if (shouldShowUpgradeMessage()) - chrome.tabs.sendMessage(senderTabId, { name: "showUpgradeNotification", version: currentVersion }) - if (portHandlers[port.name]) port.onMessage.addListener(portHandlers[port.name]) -) chrome.runtime.onMessage.addListener((request, sender, sendResponse) -> if (sendRequestHandlers[request.handler]) @@ -150,22 +162,22 @@ openUrlInCurrentTab = (request) -> # # Opens request.url in new tab and switches to it if request.selected is true. # -openUrlInNewTab = (request) -> - chrome.tabs.getSelected(null, (tab) -> - chrome.tabs.create({ url: Utils.convertToUrl(request.url), index: tab.index + 1, selected: true })) +openUrlInNewTab = (request, callback) -> + chrome.tabs.getSelected null, (tab) -> + tabConfig = + url: Utils.convertToUrl request.url + index: tab.index + 1 + selected: true + windowId: tab.windowId + # FIXME(smblott). openUrlInNewTab is being called in two different ways with different arguments. We + # should refactor it such that this check on callback isn't necessary. + callback = (->) unless typeof callback == "function" + chrome.tabs.create tabConfig, callback openUrlInIncognito = (request) -> chrome.windows.create({ url: Utils.convertToUrl(request.url), incognito: true}) # -# Called when the user has clicked the close icon on the "Vimium has been updated" message. -# We should now dismiss that message in all tabs. -# -upgradeNotificationClosed = (request) -> - Settings.set("previousVersion", currentVersion) - sendRequestToAllTabs({ name: "hideUpgradeNotification" }) - -# # Copies or pastes some data (request.data) to/from the clipboard. # We return null to avoid the return value from the copy operations being passed to sendResponse. # @@ -220,7 +232,14 @@ moveTab = (callback, direction) -> # These are commands which are bound to keystroke which must be handled by the background page. They are # mapped in commands.coffee. BackgroundCommands = - createTab: (callback) -> chrome.tabs.create({url: Settings.get("newTabUrl")}, (tab) -> callback()) + createTab: (callback) -> + chrome.tabs.query { active: true, currentWindow: true }, (tabs) -> + tab = tabs[0] + url = Settings.get "newTabUrl" + if url == "pages/blank.html" + # "pages/blank.html" does not work in incognito mode, so fall back to "chrome://newtab" instead. + url = if tab.incognito then "chrome://newtab" else chrome.runtime.getURL url + openUrlInNewTab { url }, callback duplicateTab: (callback) -> chrome.tabs.getSelected(null, (tab) -> chrome.tabs.duplicate(tab.id) @@ -336,9 +355,6 @@ updateOpenTabs = (tab, deleteFrames = false) -> # Frames are recreated on refresh delete frameIdsForTab[tab.id] if deleteFrames -setBrowserActionIcon = (tabId,path) -> - chrome.browserAction.setIcon({ tabId: tabId, path: path }) - chrome.browserAction.setBadgeBackgroundColor # This is Vimium blue (from the icon). # color: [102, 176, 226, 255] @@ -349,43 +365,33 @@ chrome.browserAction.setBadgeBackgroundColor setBadge = do -> current = null timer = null - updateBadge = (badge) -> -> chrome.browserAction.setBadgeText text: badge - (request) -> + updateBadge = (badge, tabId) -> -> chrome.browserAction.setBadgeText text: badge, tabId: tabId + (request, sender) -> badge = request.badge if badge? and badge != current current = badge clearTimeout timer if timer # We wait a few moments. This avoids badge flicker when there are rapid changes. - timer = setTimeout updateBadge(badge), 50 - -# Updates the browserAction icon to indicate whether Vimium is enabled or disabled on the current page. -# Also propagates new enabled/disabled/passkeys state to active window, if necessary. -# This lets you disable Vimium on a page without needing to reload. -# Exported via root because it's called from the page popup. -root.updateActiveState = updateActiveState = (tabId) -> - enabledIcon = "icons/browser_action_enabled.png" - disabledIcon = "icons/browser_action_disabled.png" - partialIcon = "icons/browser_action_partial.png" - chrome.tabs.get tabId, (tab) -> - setBadge badge: "" - chrome.tabs.sendMessage tabId, { name: "getActiveState" }, (response) -> - if response - isCurrentlyEnabled = response.enabled - currentPasskeys = response.passKeys - config = isEnabledForUrl { url: tab.url }, { tab: tab } - enabled = config.isEnabledForUrl - passKeys = config.passKeys - if (enabled and passKeys) - setBrowserActionIcon(tabId,partialIcon) - else if (enabled) - setBrowserActionIcon(tabId,enabledIcon) - else - setBrowserActionIcon(tabId,disabledIcon) - # Propagate the new state only if it has changed. - if (isCurrentlyEnabled != enabled || currentPasskeys != passKeys) - chrome.tabs.sendMessage(tabId, { name: "setState", enabled: enabled, passKeys: passKeys, incognito: tab.incognito }) - else - setBrowserActionIcon tabId, disabledIcon + timer = setTimeout updateBadge(badge, sender.tab.id), 50 + +# Here's how we set the page icon. The default is "disabled", so if we do nothing else, then we get the +# grey-out disabled icon. Thereafter, we only set tab-specific icons, so there's no need to update the icon +# when we visit a tab on which Vimium isn't running. +# +# For active tabs, when a frame starts, it requests its active state via isEnabledForUrl. We also check the +# state every time a frame gets the focus. Once the frame learns its active state, it updates the current +# tab's badge (but only if that frame has the focus). +# +# Exclusion rule changes (from either the options page or the page popup) propagate via the subsequent focus +# change. In particular, whenever a frame next gets the focus, it requests its new state and sets the icon +# accordingly. +# +setIcon = (request, sender) -> + path = switch request.icon + when "enabled" then "icons/browser_action_enabled.png" + when "partial" then "icons/browser_action_partial.png" + when "disabled" then "icons/browser_action_disabled.png" + chrome.browserAction.setIcon tabId: sender.tab.id, path: path handleUpdateScrollPosition = (request, sender) -> updateScrollPosition(sender.tab, request.scrollX, request.scrollY) @@ -402,7 +408,6 @@ chrome.tabs.onUpdated.addListener (tabId, changeInfo, tab) -> runAt: "document_start" chrome.tabs.insertCSS tabId, cssConf, -> chrome.runtime.lastError updateOpenTabs(tab) if changeInfo.url? - updateActiveState(tabId) chrome.tabs.onAttached.addListener (tabId, attachedInfo) -> # We should update all the tabs in the old window and the new window. @@ -437,8 +442,7 @@ chrome.tabs.onRemoved.addListener (tabId) -> tabInfoMap.deletor = -> delete tabInfoMap[tabId] setTimeout tabInfoMap.deletor, 1000 delete frameIdsForTab[tabId] - -chrome.tabs.onActiveChanged.addListener (tabId, selectInfo) -> updateActiveState(tabId) + delete urlForTab[tabId] unless chrome.sessions chrome.windows.onRemoved.addListener (windowId) -> delete tabQueue[windowId] @@ -595,16 +599,6 @@ sendRequestToAllTabs = (args) -> for tab in window.tabs chrome.tabs.sendMessage(tab.id, args, null)) -# -# Returns true if the current extension version is greater than the previously recorded version in -# localStorage, and false otherwise. -# -shouldShowUpgradeMessage = -> - # Avoid showing the upgrade notification when previousVersion is undefined, which is the case for new - # installs. - Settings.set("previousVersion", currentVersion) unless Settings.get("previousVersion") - Utils.compareVersions(currentVersion, Settings.get("previousVersion")) == 1 - openOptionsPageInNewTab = -> chrome.tabs.getSelected(null, (tab) -> chrome.tabs.create({ url: chrome.runtime.getURL("pages/options.html"), index: tab.index + 1 })) @@ -622,6 +616,7 @@ unregisterFrame = (request, sender) -> handleFrameFocused = (request, sender) -> tabId = sender.tab.id + urlForTab[tabId] = request.url if frameIdsForTab[tabId]? frameIdsForTab[tabId] = [request.frameId, (frameIdsForTab[tabId].filter (id) -> id != request.frameId)...] @@ -643,7 +638,6 @@ sendRequestHandlers = unregisterFrame: unregisterFrame frameFocused: handleFrameFocused nextFrame: (request) -> BackgroundCommands.nextFrame 1, request.frameId - upgradeNotificationClosed: upgradeNotificationClosed updateScrollPosition: handleUpdateScrollPosition copyToClipboard: copyToClipboard pasteFromClipboard: pasteFromClipboard @@ -652,6 +646,7 @@ sendRequestHandlers = refreshCompleter: refreshCompleter createMark: Marks.create.bind(Marks) gotoMark: Marks.goto.bind(Marks) + setIcon: setIcon setBadge: setBadge # We always remove chrome.storage.local/findModeRawQueryListIncognito on startup. @@ -681,8 +676,30 @@ if Settings.has("keyMappings") populateValidFirstKeys() populateSingleKeyCommands() -if shouldShowUpgradeMessage() - sendRequestToAllTabs({ name: "showUpgradeNotification", version: currentVersion }) + +# Show notification on upgrade. +showUpgradeMessage = -> + # Avoid showing the upgrade notification when previousVersion is undefined, which is the case for new + # installs. + Settings.set "previousVersion", currentVersion unless Settings.get "previousVersion" + if Utils.compareVersions(currentVersion, Settings.get "previousVersion" ) == 1 + notificationId = "VimiumUpgradeNotification" + notification = + type: "basic" + iconUrl: chrome.runtime.getURL "icons/vimium.png" + title: "Vimium Upgrade" + message: "Vimium has been upgraded to version #{currentVersion}. Click here for more information." + isClickable: true + if chrome.notifications?.create? + chrome.notifications.create notificationId, notification, -> + unless chrome.runtime.lastError + Settings.set "previousVersion", currentVersion + chrome.notifications.onClicked.addListener (id) -> + if id == notificationId + openUrlInNewTab url: "https://github.com/philc/vimium#release-notes" + else + # We need to wait for the user to accept the "notifications" permission. + chrome.permissions.onAdded.addListener showUpgradeMessage # Ensure that tabInfoMap is populated when Vimium is installed. chrome.windows.getAll { populate: true }, (windows) -> @@ -695,3 +712,4 @@ chrome.windows.getAll { populate: true }, (windows) -> # Start pulling changes from synchronized storage. Sync.init() +showUpgradeMessage() diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index 3528e8a9..a4d95c81 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -33,7 +33,7 @@ root.Settings = Settings = root.refreshCompletionKeysAfterMappingSave() searchEngines: (value) -> - root.Settings.parseSearchEngines value + root.SearchEngineCompleter.parseSearchEngines value exclusionRules: (value) -> root.Exclusions.postUpdateHook value @@ -42,27 +42,6 @@ root.Settings = Settings = performPostUpdateHook: (key, value) -> @postUpdateHooks[key] value if @postUpdateHooks[key] - # Here we have our functions that parse the search engines - # this is a map that we use to store our search engines for use. - searchEnginesMap: {} - - # Parse the custom search engines setting and cache it. - parseSearchEngines: (searchEnginesText) -> - @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 - # options.coffee and options.html only handle booleans and strings; therefore all defaults must be booleans # or strings defaults: diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee index 2abfa001..72fde9e1 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -68,6 +68,7 @@ LinkHints = @hintMode = new Mode name: "hint/#{mode.name}" badge: "#{mode.key}?" + passInitialKeyupEvents: true keydown: @onKeyDownInMode.bind(this, hintMarkers), # trap all key events keypress: -> false @@ -231,6 +232,8 @@ LinkHints = # 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. + # NOTE(smblott). filterHints.generateLinkText also assumes this order when generating the content text for + # each hint. Specifically, we consider descendents before we consider their ancestors. visibleElements = visibleElements.reverse() while visibleElement = visibleElements.pop() rects = [visibleElement.rect] @@ -469,7 +472,7 @@ filterHints = linkText = element.firstElementChild.alt || element.firstElementChild.title showLinkText = true if (linkText) else - linkText = element.textContent || element.innerHTML + linkText = DomUtils.textContent.get element { text: linkText, show: showLinkText } @@ -479,6 +482,7 @@ filterHints = fillInMarkers: (hintMarkers) -> @generateLabelMap() + DomUtils.textContent.reset() for marker, idx in hintMarkers marker.hintString = @generateHintString(idx) linkTextObject = @generateLinkText(marker.clickableItem) diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee index fa583a1c..bded402c 100644 --- a/content_scripts/mode.coffee +++ b/content_scripts/mode.coffee @@ -120,6 +120,16 @@ class Mode @registerStateChange?() registerKeyQueue: ({ keyQueue: keyQueue }) => @alwaysContinueBubbling => @keyQueue = keyQueue + # If @options.passInitialKeyupEvents is set, then we pass initial non-printable keyup events to the page + # or to other extensions (because the corresponding keydown events were passed). This is used when + # activating link hints, see #1522. + if @options.passInitialKeyupEvents + @push + _name: "mode-#{@id}/passInitialKeyupEvents" + keydown: => @alwaysContinueBubbling -> handlerStack.remove() + keyup: (event) => + if KeyboardUtils.isPrintable event then @stopBubblingAndFalse else @stopBubblingAndTrue + Mode.modes.push @ Mode.updateBadge() @logModes() diff --git a/content_scripts/mode_visual_edit.coffee b/content_scripts/mode_visual_edit.coffee index 26076123..0b4feb2b 100644 --- a/content_scripts/mode_visual_edit.coffee +++ b/content_scripts/mode_visual_edit.coffee @@ -366,12 +366,12 @@ class Movement extends CountPrefix # # End of Movement constructor. - # Yank the selection; always exits; either deletes the selection or removes it; set @yankedText and return + # Yank the selection; always exits; either deletes the selection or collapses it; set @yankedText and return # it. yank: (args = {}) -> @yankedText = @selection.toString() @selection.deleteFromDocument() if @options.deleteFromDocument or args.deleteFromDocument - @selection.removeAllRanges() unless @options.parentMode + @selection.collapseToStart() unless @options.parentMode message = @yankedText.replace /\s+/g, " " message = message[...12] + "..." if 15 < @yankedText.length @@ -384,7 +384,7 @@ class Movement extends CountPrefix exit: (event, target) -> unless @options.parentMode or @options.oneMovementOnly - @selection.removeAllRanges() if event?.type == "keydown" and KeyboardUtils.isEscape event + @selection.collapseToStart() if event?.type == "keydown" and KeyboardUtils.isEscape event # Disabled, pending discussion of fine-tuning the UX. Simpler alternative is implemented above. # # If we're exiting on escape and there is a range selection, then we leave it in place. However, an diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 5cc3fd82..b7de5140 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -123,6 +123,9 @@ CoreScroller = @lastEvent = null @keyIsDown = false + # NOTE(smblott) With extreme keyboard configurations, Chrome sometimes does not get a keyup event for + # every keydown, in which case tapping "j" scrolls indefinitely. This appears to be a Chrome/OS/XOrg bug + # of some kind. See #1549. handlerStack.push _name: 'scroller/track-key-status' keydown: (event) => diff --git a/content_scripts/ui_component.coffee b/content_scripts/ui_component.coffee index c4ed3bf6..dadc84b5 100644 --- a/content_scripts/ui_component.coffee +++ b/content_scripts/ui_component.coffee @@ -29,24 +29,25 @@ class UIComponent 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() + @show() unless @showing @iframeElement.focus() show: (message) -> @postMessage message if message? @iframeElement.classList.remove "vimiumUIComponentHidden" @iframeElement.classList.add "vimiumUIComponentShowing" + window.addEventListener "focus", @onFocus = (event) => + if event.target == window + window.removeEventListener "focus", @onFocus + @onFocus = null + @postMessage "hide" @showing = true hide: (focusWindow = true)-> @iframeElement.classList.remove "vimiumUIComponentShowing" @iframeElement.classList.add "vimiumUIComponentHidden" + window.removeEventListener "focus", @onFocus if @onFocus + @onFocus = null window.focus() if focusWindow @showing = false diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index f549e06d..409a9373 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -26,7 +26,7 @@ validFirstKeys = "" # The corresponding XPath for such elements. textInputXPath = (-> - textInputTypes = ["text", "search", "email", "url", "number", "password"] + textInputTypes = [ "text", "search", "email", "url", "number", "password", "date", "tel" ] inputElements = ["input[" + "(" + textInputTypes.map((type) -> '@type="' + type + '"').join(" or ") + "or not(@type))" + " and not(@disabled or @readonly)]", @@ -133,8 +133,6 @@ window.initializeModes = -> keypress: (event) => onKeypress.call @, event keyup: (event) => onKeyup.call @, event - Scroller.init settings - # Install the permanent modes. The permanently-installed insert mode tracks focus/blur events, and # activates/deactivates itself accordingly. new BadgeMode @@ -142,6 +140,7 @@ window.initializeModes = -> new PassKeysMode new InsertMode permanent: true new GrabBackFocus + Scroller.init settings # # Complete initialization work that sould be done prior to DOMReady. @@ -163,10 +162,9 @@ initializePreDomReady = -> isEnabledForUrl = false chrome.runtime.sendMessage = -> chrome.runtime.connect = -> + window.removeEventListener "focus", onFocus requestHandlers = - hideUpgradeNotification: -> HUD.hideUpgradeNotification() - showUpgradeNotification: (request) -> HUD.showUpgradeNotification(request.version) showHUDforDuration: (request) -> HUD.showForDuration request.text, request.duration toggleHelpDialog: (request) -> toggleHelpDialog(request.dialogHtml, request.frameId) focusFrame: (request) -> if (frameId == request.frameId) then focusThisFrame(request.highlight) @@ -174,8 +172,6 @@ initializePreDomReady = -> getScrollPosition: -> scrollX: window.scrollX, scrollY: window.scrollY setScrollPosition: (request) -> setScrollPosition request.scrollX, request.scrollY executePageCommand: executePageCommand - getActiveState: getActiveState - setState: setState currentKeyQueue: (request) -> keyQueue = request.keyQueue handlerStack.bubbleEvent "registerKeyQueue", { keyQueue: keyQueue } @@ -184,9 +180,9 @@ initializePreDomReady = -> # In the options page, we will receive requests from both content and background scripts. ignore those # from the former. return if sender.tab and not sender.tab.url.startsWith 'chrome-extension://' - return unless isEnabledForUrl or request.name == 'getActiveState' or request.name == 'setState' + return unless isEnabledForUrl # These requests are delivered to the options page, but there are no handlers there. - return if request.handler == "registerFrame" or request.handler == "frameFocused" + return if request.handler in [ "registerFrame", "frameFocused", "unregisterFrame" ] sendResponse requestHandlers[request.name](request, sender) # Ensure the sendResponse callback is freed. false @@ -213,26 +209,22 @@ window.initializeWhenEnabled = -> installedListeners = true FindModeHistory.init() -setState = (request) -> - isEnabledForUrl = request.enabled - passKeys = request.passKeys - isIncognitoMode = request.incognito - initializeWhenEnabled() if isEnabledForUrl - handlerStack.bubbleEvent "registerStateChange", - enabled: isEnabledForUrl - passKeys: passKeys - -getActiveState = -> - Mode.updateBadge() - return { enabled: isEnabledForUrl, passKeys: passKeys } - # -# The backend needs to know which frame has focus. +# Whenever we get the focus: +# - Reload settings (they may have changed). +# - Tell the background page this frame's URL. +# - Check if we should be enabled. # -window.addEventListener "focus", -> - # settings may have changed since the frame last had focus - settings.load() - chrome.runtime.sendMessage({ handler: "frameFocused", frameId: frameId }) +onFocus = (event) -> + if event.target == window + settings.load() + chrome.runtime.sendMessage handler: "frameFocused", frameId: frameId, url: window.location.toString() + checkIfEnabledForUrl() + +# We install these listeners directly (that is, we don't use installListener) because we still need to receive +# events when Vimium is not enabled. +window.addEventListener "focus", onFocus +window.addEventListener "hashchange", onFocus # # Initialization tasks that must wait for the document to be ready. @@ -565,12 +557,21 @@ checkIfEnabledForUrl = -> isIncognitoMode = response.incognito if isEnabledForUrl initializeWhenEnabled() - else if (HUD.isReady()) + else if HUD.isReady() # Quickly hide any HUD we might already be showing, e.g. if we entered insert mode on page load. HUD.hide() handlerStack.bubbleEvent "registerStateChange", enabled: isEnabledForUrl passKeys: passKeys + # Update the page icon, if necessary. + if document.hasFocus() + chrome.runtime.sendMessage + handler: "setIcon" + icon: + if isEnabledForUrl and not passKeys then "enabled" + else if isEnabledForUrl then "partial" + else "disabled" + # Exported to window, but only for DOM tests. window.refreshCompletionKeys = (response) -> @@ -801,8 +802,7 @@ executeFind = (query, options) -> # previous find landed in an editable element, then that element may still be activated. In this case, we # don't want to leave it behind (see #1412). if document.activeElement and DomUtils.isEditable document.activeElement - if not DomUtils.isSelected document.activeElement - document.activeElement.blur() + document.activeElement.blur() unless DomUtils.isSelected document.activeElement # we need to save the anchor node here because <esc> seems to nullify it, regardless of whether we do # preventDefault() @@ -1072,7 +1072,6 @@ toggleHelpDialog = (html, fid) -> HUD = _tweenId: -1 _displayElement: null - _upgradeNotificationElement: null # This HUD is styled to precisely mimick the chrome HUD on Mac. Use the "has_popup_and_link_hud.html" # test harness to tweak these styles to match Chrome's. One limitation of our HUD display is that @@ -1090,26 +1089,6 @@ HUD = HUD._tweenId = Tween.fade(HUD.displayElement(), 1.0, 150) HUD.displayElement().style.display = "" - showUpgradeNotification: (version) -> - HUD.upgradeNotificationElement().innerHTML = "Vimium has been upgraded to #{version}. See - <a class='vimiumReset' target='_blank' - href='https://github.com/philc/vimium#release-notes'> - what's new</a>.<a class='vimiumReset close-button' href='#'>×</a>" - links = HUD.upgradeNotificationElement().getElementsByTagName("a") - links[0].addEventListener("click", HUD.onUpdateLinkClicked, false) - links[1].addEventListener "click", (event) -> - event.preventDefault() - HUD.onUpdateLinkClicked() - Tween.fade(HUD.upgradeNotificationElement(), 1.0, 150) - - onUpdateLinkClicked: (event) -> - HUD.hideUpgradeNotification() - chrome.runtime.sendMessage({ handler: "upgradeNotificationClosed" }) - - hideUpgradeNotification: (clickEvent) -> - Tween.fade(HUD.upgradeNotificationElement(), 0, 150, - -> HUD.upgradeNotificationElement().style.display = "none") - # # Retrieves the HUD HTML element. # @@ -1120,13 +1099,6 @@ HUD = HUD._displayElement.style.right = "150px" HUD._displayElement - upgradeNotificationElement: -> - if (!HUD._upgradeNotificationElement) - HUD._upgradeNotificationElement = HUD.createHudElement() - # Position this just to the left of our normal HUD. - HUD._upgradeNotificationElement.style.right = "315px" - HUD._upgradeNotificationElement - createHudElement: -> element = document.createElement("div") element.className = "vimiumReset vimiumHUD" diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee index 6381fd7f..c4cfc8b9 100644 --- a/content_scripts/vomnibar.coffee +++ b/content_scripts/vomnibar.coffee @@ -38,7 +38,10 @@ Vomnibar = init: -> unless @vomnibarUI? @vomnibarUI = new UIComponent "pages/vomnibar.html", "vomnibarFrame", (event) => - @vomnibarUI.hide() if event.data == "hide" + if event.data == "hide" + @vomnibarUI.hide() + @vomnibarUI.postMessage "hidden" + # This function opens the vomnibar. It accepts options, a map with the values: # completer - The completer to fetch results from. diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index 2ae9412e..0231f994 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -167,15 +167,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 +190,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 +306,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/manifest.json b/manifest.json index beb68530..4ba222fb 100644 --- a/manifest.json +++ b/manifest.json @@ -27,6 +27,7 @@ "clipboardRead", "storage", "sessions", + "notifications", "<all_urls>" ], "content_scripts": [ diff --git a/pages/help_dialog.html b/pages/help_dialog.html index 0884f2cd..77c3e2bf 100644 --- a/pages/help_dialog.html +++ b/pages/help_dialog.html @@ -46,6 +46,7 @@ </div> <div class="vimiumReset vimiumColumn" style="text-align:right"> <span class="vimiumReset">Version {{version}}</span><br/> + <a href="https://github.com/philc/vimium#release-notes" class="vimiumReset">What's new?</a> </div> </div> </div> diff --git a/pages/options.coffee b/pages/options.coffee index d2950348..6545189b 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -271,8 +271,12 @@ initPopupPage = -> exclusions = null document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html") + # As the active URL, we choose the most recently registered URL from a frame in the tab, or the tab's own + # URL. + url = chrome.extension.getBackgroundPage().urlForTab[tab.id] || tab.url + updateState = -> - rule = bgExclusions.getRule tab.url, exclusions.readValueFromElement() + rule = bgExclusions.getRule url, exclusions.readValueFromElement() $("state").innerHTML = "Vimium will " + if rule and rule.passKeys "exclude <span class='code'>#{rule.passKeys}</span>" @@ -291,8 +295,6 @@ initPopupPage = -> Option.saveOptions() $("saveOptions").innerHTML = "Saved" $("saveOptions").disabled = true - chrome.tabs.query { windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, (tabs) -> - chrome.extension.getBackgroundPage().updateActiveState(tabs[0].id) $("saveOptions").addEventListener "click", saveOptions @@ -302,7 +304,7 @@ initPopupPage = -> window.close() # Populate options. Just one, here. - exclusions = new ExclusionRulesOnPopupOption(tab.url, "exclusionRules", onUpdated) + exclusions = new ExclusionRulesOnPopupOption url, "exclusionRules", onUpdated updateState() document.addEventListener "keyup", updateState diff --git a/pages/options.html b/pages/options.html index 889d5ea0..f89ddcbb 100644 --- a/pages/options.html +++ b/pages/options.html @@ -200,7 +200,7 @@ b: http://b.com/?q=%s description <div class="help"> <div class="example"> The page to open with the "create new tab" command. - Set this to "<tt>pages/blank.html</tt>" for a blank page.<br /> + Set this to "<tt>pages/blank.html</tt>" for a blank page (except incognito mode).<br /> </div> </div> <input id="newTabUrl" type="text" /> diff --git a/pages/vomnibar.coffee b/pages/vomnibar.coffee index 18a72a37..06ec9ee9 100644 --- a/pages/vomnibar.coffee +++ b/pages/vomnibar.coffee @@ -38,9 +38,13 @@ Vomnibar = @vomnibarUI.setQuery(options.query) @vomnibarUI.update() + hide: -> @vomnibarUI?.hide() + onHidden: -> @vomnibarUI?.onHidden() + class VomnibarUI constructor: -> @refreshInterval = 0 + @postHideCallback = null @initDom() setQuery: (query) -> @input.value = query @@ -57,9 +61,20 @@ class VomnibarUI setForceNewTab: (forceNewTab) -> @forceNewTab = forceNewTab - hide: -> + # The sequence of events when the vomnibar is hidden is as follows: + # 1. Post a "hide" message to the host page. + # 2. The host page hides the vomnibar and posts back a "hidden" message. + # 3. Only once "hidden" message is received here is any required action (callback) invoked (in onHidden). + # This ensures that the vomnibar is actually hidden, and avoids flicker after opening a link in a new tab + # (see #1485). + hide: (callback = null) -> UIComponentServer.postMessage "hide" @reset() + @postHideCallback = callback + + onHidden: -> + @postHideCallback?() + @postHideCallback = null reset: -> @completionList.style.display = "" @@ -121,15 +136,15 @@ class VomnibarUI 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 }) + @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() + completion = @completions[@selection] + @hide -> completion.performAction openInNewTab # It seems like we have to manually suppress the event here and still return true. event.stopImmediatePropagation() @@ -180,6 +195,12 @@ class VomnibarUI @completionList.style.display = "" window.addEventListener "focus", => @input.focus() + # A click in the vomnibar itself refocuses the input. + @box.addEventListener "click", (event) => + @input.focus() + event.stopImmediatePropagation() + # A click anywhere else hides the vomnibar. + document.body.addEventListener "click", => @hide() # # Sends filter and refresh requests to a Vomnibox completer on the background page. @@ -225,7 +246,11 @@ extend BackgroundCompleter, switchToTab: (tabId) -> chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: tabId }) -UIComponentServer.registerHandler (event) -> Vomnibar.activate event.data +UIComponentServer.registerHandler (event) -> + switch event.data + when "hide" then Vomnibar.hide() + when "hidden" then Vomnibar.onHidden() + else Vomnibar.activate event.data root = exports ? window root.Vomnibar = Vomnibar diff --git a/tests/dom_tests/dom_tests.coffee b/tests/dom_tests/dom_tests.coffee index a1ca8723..4afa9d7d 100644 --- a/tests/dom_tests/dom_tests.coffee +++ b/tests/dom_tests/dom_tests.coffee @@ -84,6 +84,47 @@ createGeneralHintTests = (isFilteredMode) -> createGeneralHintTests false createGeneralHintTests true +inputs = [] +context "Test link hints for focusing input elements correctly", + + setup -> + initializeModeState() + testDiv = document.getElementById("test-div") + testDiv.innerHTML = "" + + stub settings.values, "filterLinkHints", false + stub settings.values, "linkHintCharacters", "ab" + + # Every HTML5 input type except for hidden. We should be able to activate all of them with link hints. + inputTypes = ["button", "checkbox", "color", "date", "datetime", "datetime-local", "email", "file", + "image", "month", "number", "password", "radio", "range", "reset", "search", "submit", "tel", "text", + "time", "url", "week"] + + for type in inputTypes + input = document.createElement "input" + input.type = type + testDiv.appendChild input + inputs.push input + + tearDown -> + document.getElementById("test-div").innerHTML = "" + + should "Focus each input when its hint text is typed", -> + for input in inputs + input.scrollIntoView() # Ensure the element is visible so we create a link hint for it. + + activeListener = ensureCalled (event) -> + input.blur() if event.type == "focus" + input.addEventListener "focus", activeListener, false + input.addEventListener "click", activeListener, false + + LinkHints.activateMode() + [hint] = getHintMarkers().filter (hint) -> input == hint.clickableItem + sendKeyboardEvent char for char in hint.hintString + + input.removeEventListener "focus", activeListener, false + input.removeEventListener "click", activeListener, false + context "Alphabetical link hints", setup -> @@ -501,7 +542,8 @@ context "Mode badges", should "have an I badge in insert mode by focus", -> document.getElementById("first").focus() - assert.isTrue chromeMessages[0].badge == "I" + # Focus triggers an event in the handler stack, so we check element "1", here. + assert.isTrue chromeMessages[1].badge == "I" should "have no badge after leaving insert mode by focus", -> document.getElementById("first").focus() @@ -534,5 +576,6 @@ context "Mode badges", passKeys: "" document.getElementById("first").focus() - assert.isTrue chromeMessages[0].badge == "" + # Focus triggers an event in the handler stack, so we check element "1", here. + assert.isTrue chromeMessages[1].badge == "" diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index b7b73cc2..56fcc456 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -152,8 +152,9 @@ context "domain completer", setup -> @history1 = { title: "history1", url: "http://history1.com", lastVisitTime: hours(1) } @history2 = { title: "history2", url: "http://history2.com", lastVisitTime: hours(1) } + @undef = { title: "history2", url: "http://undefined.net", lastVisitTime: hours(1) } - stub(HistoryCache, "use", (onComplete) => onComplete([@history1, @history2])) + stub(HistoryCache, "use", (onComplete) => onComplete([@history1, @history2, @undef])) global.chrome.history = onVisited: { addListener: -> } onVisitRemoved: { addListener: -> } @@ -174,6 +175,9 @@ context "domain completer", should "returns no results when there's more than one query term, because clearly it's not a domain", -> assert.arrayEqual [], filterCompleter(@completer, ["his", "tory"]) + should "not return any results for empty queries", -> + assert.arrayEqual [], filterCompleter(@completer, []) + context "domain completer (removing entries)", setup -> @history1 = { title: "history1", url: "http://history1.com", lastVisitTime: hours(2) } @@ -238,7 +242,7 @@ context "search engines", @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() + @completer.searchEngines = SearchEngineCompleter.getSearchEngines() should "return search engine suggestion without description", -> results = filterCompleter(@completer, ["foo", "hello"]) diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee index afe862a4..346c98da 100644 --- a/tests/unit_tests/settings_test.coffee +++ b/tests/unit_tests/settings_test.coffee @@ -73,7 +73,7 @@ context "settings", 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 - result = Settings.getSearchEngines() + result = SearchEngineCompleter.getSearchEngines() assert.equal Object.keys(result).length, 2 assert.equal "bar?q=%s", result["foo"].url assert.isFalse result["foo"].description diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index c61d7246..bc50521a 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -16,6 +16,8 @@ exports.chrome = addListener: () -> true onMessage: addListener: () -> true + onInstalled: + addListener: -> tabs: onSelectionChanged: |
