aboutsummaryrefslogtreecommitdiffstats
path: root/pages/vomnibar.coffee
diff options
context:
space:
mode:
Diffstat (limited to 'pages/vomnibar.coffee')
-rw-r--r--pages/vomnibar.coffee369
1 files changed, 239 insertions, 130 deletions
diff --git a/pages/vomnibar.coffee b/pages/vomnibar.coffee
index 18a72a37..d5659fdc 100644
--- a/pages/vomnibar.coffee
+++ b/pages/vomnibar.coffee
@@ -9,79 +9,97 @@ Vomnibar =
completers: {}
getCompleter: (name) ->
- if (!(name of @completers))
- @completers[name] = new BackgroundCompleter(name)
- @completers[name]
+ @completers[name] ?= new BackgroundCompleter name
- #
- # Activate the Vomnibox.
- #
activate: (userOptions) ->
options =
completer: "omni"
query: ""
newTab: false
selectFirst: false
+ keyword: null
extend options, userOptions
+ extend options, refreshInterval: if options.completer == "omni" then 150 else 0
- options.refreshInterval = switch options.completer
- when "omni" then 100
- else 0
-
- completer = @getCompleter(options.completer)
+ 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()
+ completer.refresh @vomnibarUI
+ @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.setKeyword options.keyword
+ @vomnibarUI.update true
+
+ hide: -> @vomnibarUI?.hide()
+ onHidden: -> @vomnibarUI?.onHidden()
class VomnibarUI
constructor: ->
@refreshInterval = 0
+ @postHideCallback = null
@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: ->
+ setKeyword: (keyword) -> @customSearchMode = keyword
+ setInitialSelectionValue: (@initialSelectionValue) ->
+ setRefreshInterval: (@refreshInterval) ->
+ setForceNewTab: (@forceNewTab) ->
+ setCompleter: (@completer) -> @reset()
+ setKeywords: (@keywords) ->
+
+ # 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.
+ # 3. When that page receives the focus, and it posts back a "hidden" message.
+ # 3. Only once the "hidden" message is received here is any required action invoked (in onHidden).
+ # This ensures that the vomnibar is actually hidden before any new tab is created, and avoids flicker after
+ # opening a link in a new tab then returning to the original tab (see #1485).
+ hide: (@postHideCallback = null) ->
UIComponentServer.postMessage "hide"
@reset()
+ @completer?.reset()
+
+ onHidden: ->
+ @postHideCallback?()
+ @postHideCallback = null
reset: ->
+ @clearUpdateTimer()
@completionList.style.display = ""
@input.value = ""
- @updateTimer = null
@completions = []
+ @previousInputValue = null
+ @customSearchMode = null
@selection = @initialSelectionValue
+ @keywords = []
+ @seenTabToOpenCompletionList = false
updateSelection: ->
- # We retain global state here (previousAutoSelect) to tell if a search item (for which autoSelect is set)
- # has just appeared or disappeared. If that happens, we set @selection to 0 or -1.
- if @completions[0]
- @selection = 0 if @completions[0].autoSelect and not @previousAutoSelect
- @selection = -1 if @previousAutoSelect and not @completions[0].autoSelect
- @previousAutoSelect = @completions[0].autoSelect
+ # For custom search engines, we suppress the leading term (e.g. the "w" of "w query terms") within the
+ # vomnibar input.
+ if @lastReponse.isCustomSearch and not @customSearchMode?
+ queryTerms = @input.value.trim().split /\s+/
+ @customSearchMode = queryTerms[0]
+ @input.value = queryTerms[1..].join " "
+
+ # For suggestions for custom search engines, we copy the suggested text into the input when the item is
+ # selected, and revert when it is not. This allows the user to select a suggestion and then continue
+ # typing.
+ if 0 <= @selection and @completions[@selection].insertText?
+ @previousInputValue ?= @input.value
+ @input.value = @completions[@selection].insertText
+ else if @previousInputValue?
+ @input.value = @previousInputValue
+ @previousInputValue = null
+
+ # Highlight the selected entry, and only the selected entry.
for i in [0...@completionList.children.length]
@completionList.children[i].className = (if i == @selection then "vomnibarSelected" else "")
- #
- # 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.
- #
+ # Returns the user's action ("up", "down", "tab", etc, or null) based on their keypress. We support the
+ # arrow keys and various other shortcuts, and this function hides the event-decoding complexity.
actionFromKeyEvent: (event) ->
key = KeyboardUtils.getKeyChar(event)
if (KeyboardUtils.isEscape(event))
@@ -90,83 +108,139 @@ class VomnibarUI
(event.shiftKey && event.keyCode == keyCodes.tab) ||
(event.ctrlKey && (key == "k" || key == "p")))
return "up"
+ else if (event.keyCode == keyCodes.tab && !event.shiftKey)
+ return "tab"
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"
+ else if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey
+ return "delete"
+
+ null
onKeydown: (event) =>
- action = @actionFromKeyEvent(event)
+ @lastAction = 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 in [ "tab", "down" ]
+ if action == "tab" and
+ @completer.name == "omni" and
+ not @seenTabToOpenCompletionList and
+ @input.value.trim().length == 0
+ @seenTabToOpenCompletionList = true
+ @update true
+ else
+ @selection += 1
+ @selection = @initialSelectionValue if @selection == @completions.length
+ @updateSelection()
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)
+ isCustomSearchPrimarySuggestion = @completions[@selection]?.isPrimarySuggestion and @lastReponse.engine?.searchUrl?
+ if @selection == -1 or isCustomSearchPrimarySuggestion
query = @input.value.trim()
- # <Enter> on an empty vomnibar is a no-op.
+ # <Enter> on an empty query is a no-op.
return unless 0 < query.length
- @hide()
- chrome.runtime.sendMessage({
- handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab"
- url: query })
+ # First case (@selection == -1).
+ # If the user types something and hits enter without selecting a completion from the list, then:
+ # - If a search URL has been provided, then use it. This is custom search engine request.
+ # - Otherwise, send the query to the background page, which will open it as a URL or create a
+ # default search, as appropriate.
+ #
+ # Second case (isCustomSearchPrimarySuggestion).
+ # Alternatively, the selected completion could be the primary selection for a custom search engine.
+ # Because the the suggestions are updated asynchronously in omni mode, the user may have typed more
+ # text than that which is included in the URL associated with the primary suggestion. Therefore, to
+ # avoid a race condition, we construct the query from the actual contents of the input (query).
+ query = Utils.createSearchUrl query, @lastReponse.engine.searchUrl if isCustomSearchPrimarySuggestion
+ @hide ->
+ chrome.runtime.sendMessage
+ handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab"
+ url: query
+ else
+ completion = @completions[@selection]
+ @hide -> completion.performAction openInNewTab
+ else if action == "delete"
+ inputIsEmpty = @input.value.length == 0
+ if inputIsEmpty and @customSearchMode?
+ # Normally, with custom search engines, the keyword (e,g, the "w" of "w query terms") is suppressed.
+ # If the input is empty, then reinstate the keyword (the "w").
+ @input.value = @customSearchMode
+ @customSearchMode = null
+ @update true
+ else if inputIsEmpty and @seenTabToOpenCompletionList
+ @seenTabToOpenCompletionList = false
+ @update true
else
- @update true, =>
- # Shift+Enter will open the result in a new tab instead of the current tab.
- @completions[@selection].performAction(openInNewTab)
- @hide()
+ return true # Do not suppress event.
# 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)
+ # Return the background-page query corresponding to the current input state. In other words, reinstate any
+ # search engine keyword which is currently being suppressed, and strip any prompted text.
+ getInputValueAsQuery: ->
+ (if @customSearchMode? then @customSearchMode + " " else "") + @input.value
+
+ updateCompletions: (callback = null) ->
+ @completer.filter
+ query: @getInputValueAsQuery()
+ seenTabToOpenCompletionList: @seenTabToOpenCompletionList
+ callback: (@lastReponse) =>
+ { results } = @lastReponse
+ @completions = results
+ @selection = if @completions[0]?.autoSelect then 0 else @initialSelectionValue
+ # Update completion list with the new suggestions.
+ @completionList.innerHTML = @completions.map((completion) -> "<li>#{completion.html}</li>").join("")
+ @completionList.style.display = if @completions.length > 0 then "block" else ""
+ @selection = Math.min @completions.length - 1, Math.max @initialSelectionValue, @selection
+ @updateSelection()
+ callback?()
+
+ onInput: =>
+ @seenTabToOpenCompletionList = false
+ @completer.cancel()
+ if 0 <= @selection and @completions[@selection].customSearchMode and not @customSearchMode
+ @customSearchMode = @completions[@selection].customSearchMode
+ updateSynchronously = true
+ # If the user types, then don't reset any previous text, and reset the selection.
+ if @previousInputValue?
+ @previousInputValue = null
+ @selection = -1
+ @update updateSynchronously
+
+ clearUpdateTimer: ->
+ if @updateTimer?
+ window.clearTimeout @updateTimer
+ @updateTimer = null
+
+ shouldActivateCustomSearchMode: ->
+ queryTerms = @input.value.ltrim().split /\s+/
+ 1 < queryTerms.length and queryTerms[0] in @keywords and not @customSearchMode
+
+ update: (updateSynchronously = false, callback = null) =>
+ # If the query text becomes a custom search (the user enters a search keyword), then we need to force a
+ # synchronous update (so that the state is updated immediately).
+ updateSynchronously ||= @shouldActivateCustomSearchMode()
+ if updateSynchronously
+ @clearUpdateTimer()
+ @updateCompletions callback
+ else if not @updateTimer?
+ # Update asynchronously for a better user experience, and to take some load off the CPU (not every
+ # keystroke will cause a dedicated update).
+ @updateTimer = Utils.setTimeout @refreshInterval, =>
@updateTimer = null
- @refreshInterval)
+ @updateCompletions callback
@input.focus()
@@ -174,58 +248,93 @@ class VomnibarUI
@box = document.getElementById("vomnibar")
@input = @box.querySelector("input")
- @input.addEventListener "input", @update
+ @input.addEventListener "input", @onInput
@input.addEventListener "keydown", @onKeydown
@completionList = @box.querySelector("ul")
@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.
+# Sends 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". */
+ # The "name" is the background-page completer to connect to: "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.
- #
+ @port = chrome.runtime.connect name: "completions"
+ @messageId = null
+ @reset()
+
+ @port.onMessage.addListener (msg) =>
+ switch msg.handler
+ when "keywords"
+ @keywords = msg.keywords
+ @lastUI.setKeywords @keywords
+ when "completions"
+ if msg.id == @messageId
+ # 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, search], or a custom search engine description.
+ for result in msg.results
+ extend result,
+ performAction:
+ if result.type == "tab"
+ @completionActions.switchToTab result.tabId
+ else
+ @completionActions.navigateToUrl result.url
+
+ # Handle the message, but only if it hasn't arrived too late.
+ @mostRecentCallback msg
+
+ filter: (request) ->
+ { query, callback } = request
+ @mostRecentCallback = callback
+
+ @port.postMessage extend request,
+ handler: "filter"
+ name: @name
+ id: @messageId = Utils.createUniqueId()
+ queryTerms: query.trim().split(/\s+/).filter (s) -> 0 < s.length
+ # We don't send these keys.
+ callback: null
+
+ reset: ->
+ @keywords = []
+
+ refresh: (@lastUI) ->
+ @reset()
+ @port.postMessage name: @name, handler: "refresh"
+
+ cancel: ->
+ # Inform the background completer that it may (should it choose to do so) abandon any pending query
+ # (because the user is typing, and there will be another query along soon).
+ @port.postMessage name: @name, handler: "cancel"
+
+ # These are the actions we can perform when the user selects a result.
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(
+ navigateToUrl: (url) -> (openInNewTab) ->
+ # If the URL is a bookmarklet (so, prefixed with "javascript:"), then we always open it in the current
+ # tab.
+ openInNewTab &&= not Utils.hasJavascriptPrefix url
+ chrome.runtime.sendMessage
handler: if openInNewTab then "openUrlInNewTab" else "openUrlInCurrentTab"
- url: url,
- selected: openInNewTab)
+ url: url
+ selected: openInNewTab
- switchToTab: (tabId) -> chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: tabId })
+ 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