aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorStephen Blott2015-06-25 05:28:01 +0100
committerStephen Blott2015-06-25 05:28:01 +0100
commitcb42acfc59c10df4c0fd56f068c83c7509d5a237 (patch)
treef5caba89ec0472f42631d653ce21c55a242ecd03
parentc404f6799bb748754f91b1d515108706a024dce1 (diff)
parente5faeff7d48a206a080a56a6541963d2d3c86da7 (diff)
downloadvimium-cb42acfc59c10df4c0fd56f068c83c7509d5a237.tar.bz2
Merge branch 'hud-iframe-input-with-store-all-settings'
-rw-r--r--content_scripts/hud.coffee40
-rw-r--r--content_scripts/mode_find.coffee156
-rw-r--r--content_scripts/mode_visual_edit.coffee29
-rw-r--r--content_scripts/vimium.css16
-rw-r--r--content_scripts/vimium_frontend.coffee288
-rw-r--r--lib/dom_utils.coffee12
-rw-r--r--lib/find_mode_history.coffee50
-rw-r--r--lib/settings.coffee87
-rw-r--r--manifest.json1
-rw-r--r--pages/hud.coffee87
-rw-r--r--pages/hud.html3
-rw-r--r--tests/dom_tests/dom_tests.html1
-rw-r--r--tests/unit_tests/settings_test.coffee12
-rw-r--r--tests/unit_tests/test_chrome_stubs.coffee6
14 files changed, 458 insertions, 330 deletions
diff --git a/content_scripts/hud.coffee b/content_scripts/hud.coffee
index 84b8abeb..bfad71b7 100644
--- a/content_scripts/hud.coffee
+++ b/content_scripts/hud.coffee
@@ -6,6 +6,7 @@ HUD =
tween: null
hudUI: null
_displayElement: null
+ findMode: 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
@@ -26,6 +27,19 @@ HUD =
@hudUI.show {name: "show", text}
@tween.fade 1.0, 150
+ showFindMode: (@findMode = null) ->
+ return unless @enabled()
+ @hudUI.show {name: "showFindMode", text: ""}
+ @tween.fade 1.0, 150
+
+ search: (data) ->
+ @findMode.findInPlace data.query
+
+ # Show the number of matches in the HUD UI.
+ matchCount = if FindMode.query.parsedQuery.length > 0 then FindMode.query.matchCount else 0
+ showMatchText = FindMode.query.rawQuery.length > 0
+ @hudUI.postMessage {name: "updateMatchesCount", matchCount, showMatchText}
+
# Hide the HUD.
# If :immediate is falsy, then the HUD is faded out smoothly (otherwise it is hidden immediately).
# If :updateIndicator is truthy, then we also refresh the mode indicator. The only time we don't update the
@@ -42,6 +56,32 @@ HUD =
else
@tween.fade 0, 150, => @hide true, updateIndicator
+ hideFindMode: (data) ->
+ @findMode.checkReturnToViewPort()
+
+ # An element element won't receive a focus event if the search landed on it while we were in the HUD
+ # iframe. To end up with the correct modes active, we create a focus/blur event manually after refocusing
+ # this window.
+ window.focus()
+
+ focusNode = DomUtils.getSelectionFocusElement()
+ document.activeElement?.blur()
+ focusNode?.focus()
+
+ {event} = data
+
+ if event.keyCode == keyCodes.enter
+ handleEnterForFindMode()
+ if FindMode.query.hasResults
+ postExit = -> new PostFindMode
+ else if KeyboardUtils.isEscape event
+ # We don't want FindMode to handle the click events that handleEscapeForFindMode can generate, so we
+ # wait until the mode is closed before running it.
+ postExit = handleEscapeForFindMode
+
+ @findMode.exit()
+ postExit?()
+
isReady: do ->
ready = false
DomUtils.documentReady -> ready = true
diff --git a/content_scripts/mode_find.coffee b/content_scripts/mode_find.coffee
index ed08fbd5..d7c628be 100644
--- a/content_scripts/mode_find.coffee
+++ b/content_scripts/mode_find.coffee
@@ -54,5 +54,161 @@ class PostFindMode extends SuppressPrintable
handlerStack.remove()
@continueBubbling
+class FindMode extends Mode
+ @query:
+ rawQuery: ""
+ matchCount: 0
+ hasResults: false
+
+ constructor: (options = {}) ->
+ # Save the selection, so findInPlace can restore it.
+ @initialRange = getCurrentRange()
+ FindMode.query = rawQuery: ""
+ if options.returnToViewport
+ @scrollX = window.scrollX
+ @scrollY = window.scrollY
+ super extend options,
+ name: "find"
+ indicator: false
+ exitOnClick: true
+
+ HUD.showFindMode this
+
+ exit: (event) ->
+ super()
+ handleEscapeForFindMode() if event
+
+ restoreSelection: ->
+ range = @initialRange
+ selection = getSelection()
+ selection.removeAllRanges()
+ selection.addRange range
+
+ findInPlace: (query) ->
+ # If requested, restore the scroll position (so that failed searches leave the scroll position unchanged).
+ @checkReturnToViewPort()
+ FindMode.updateQuery query
+ # Restore the selection. That way, we're always searching forward from the same place, so we find the right
+ # match as the user adds matching characters, or removes previously-matched characters. See #1434.
+ @restoreSelection()
+ query = if FindMode.query.isRegex then FindMode.getNextQueryFromRegexMatches(0) else FindMode.query.parsedQuery
+ FindMode.query.hasResults = FindMode.execute query
+
+ @updateQuery: (query) ->
+ @query.rawQuery = query
+ # the query can be treated differently (e.g. as a plain string versus regex depending on the presence of
+ # escape sequences. '\' is the escape character and needs to be escaped itself to be used as a normal
+ # character. here we grep for the relevant escape sequences.
+ @query.isRegex = Settings.get 'regexFindMode'
+ hasNoIgnoreCaseFlag = false
+ @query.parsedQuery = @query.rawQuery.replace /(\\{1,2})([rRI]?)/g, (match, slashes, flag) ->
+ return match if flag == "" or slashes.length != 1
+ switch (flag)
+ when "r"
+ @query.isRegex = true
+ when "R"
+ @query.isRegex = false
+ when "I"
+ hasNoIgnoreCaseFlag = true
+ ""
+
+ # default to 'smartcase' mode, unless noIgnoreCase is explicitly specified
+ @query.ignoreCase = !hasNoIgnoreCaseFlag && !Utils.hasUpperCase(@query.parsedQuery)
+
+ # if we are dealing with a regex, grep for all matches in the text, and then call window.find() on them
+ # sequentially so the browser handles the scrolling / text selection.
+ if @query.isRegex
+ try
+ pattern = new RegExp(@query.parsedQuery, "g" + (if @query.ignoreCase then "i" else ""))
+ catch error
+ # if we catch a SyntaxError, assume the user is not done typing yet and return quietly
+ return
+ # innerText will not return the text of hidden elements, and strip out tags while preserving newlines
+ text = document.body.innerText
+ @query.regexMatches = text.match(pattern)
+ @query.activeRegexIndex = 0
+ @query.matchCount = @query.regexMatches?.length
+ # if we are doing a basic plain string match, we still want to grep for matches of the string, so we can
+ # show a the number of results. We can grep on document.body.innerText, as it should be indistinguishable
+ # from the internal representation used by window.find.
+ else
+ # escape all special characters, so RegExp just parses the string 'as is'.
+ # Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
+ escapeRegExp = /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g
+ parsedNonRegexQuery = @query.parsedQuery.replace(escapeRegExp, (char) -> "\\" + char)
+ pattern = new RegExp(parsedNonRegexQuery, "g" + (if @query.ignoreCase then "i" else ""))
+ text = document.body.innerText
+ @query.matchCount = text.match(pattern)?.length
+
+ @getNextQueryFromRegexMatches: (stepSize) ->
+ # find()ing an empty query always returns false
+ return "" unless @query.regexMatches
+
+ totalMatches = @query.regexMatches.length
+ @query.activeRegexIndex += stepSize + totalMatches
+ @query.activeRegexIndex %= totalMatches
+
+ @query.regexMatches[@query.activeRegexIndex]
+
+ @getQuery: (backwards) ->
+ # check if the query has been changed by a script in another frame
+ mostRecentQuery = FindModeHistory.getQuery()
+ if (mostRecentQuery != @query.rawQuery)
+ @updateQuery mostRecentQuery
+
+ if @query.isRegex
+ @getNextQueryFromRegexMatches(if backwards then -1 else 1)
+ else
+ @query.parsedQuery
+
+ @saveQuery: -> FindModeHistory.saveQuery @query.rawQuery
+
+ # :options is an optional dict. valid parameters are 'caseSensitive' and 'backwards'.
+ @execute: (query, options) ->
+ result = null
+ options = extend {
+ backwards: false
+ caseSensitive: !@query.ignoreCase
+ colorSelection: true
+ }, options
+ query ?= FindMode.getQuery options.backwards
+
+ if options.colorSelection
+ document.body.classList.add("vimiumFindMode")
+ # ignore the selectionchange event generated by find()
+ document.removeEventListener("selectionchange", @restoreDefaultSelectionHighlight, true)
+
+ result = window.find(query, options.caseSensitive, options.backwards, true, false, true, false)
+
+ if options.colorSelection
+ setTimeout(
+ -> document.addEventListener("selectionchange", @restoreDefaultSelectionHighlight, true)
+ , 0)
+
+ # We are either in normal mode ("n"), or find mode ("/"). We are not in insert mode. Nevertheless, if a
+ # 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
+ document.activeElement.blur() unless DomUtils.isSelected document.activeElement
+
+ result
+
+ @restoreDefaultSelectionHighlight: -> document.body.classList.remove("vimiumFindMode")
+
+ checkReturnToViewPort: ->
+ window.scrollTo @scrollX, @scrollY if @options.returnToViewport
+
+getCurrentRange = ->
+ selection = getSelection()
+ if selection.type == "None"
+ range = document.createRange()
+ range.setStart document.body, 0
+ range.setEnd document.body, 0
+ range
+ else
+ selection.collapseToStart() if selection.type == "Range"
+ selection.getRangeAt 0
+
root = exports ? window
root.PostFindMode = PostFindMode
+root.FindMode = FindMode
diff --git a/content_scripts/mode_visual_edit.coffee b/content_scripts/mode_visual_edit.coffee
index 58e4f815..ce3caafe 100644
--- a/content_scripts/mode_visual_edit.coffee
+++ b/content_scripts/mode_visual_edit.coffee
@@ -351,22 +351,21 @@ class Movement extends CountPrefix
# element), or if this instance has been created to execute only a single movement.
unless @options.parentMode or options.oneMovementOnly
do =>
- executeFind = (count, findBackwards) =>
- if query = getFindModeQuery findBackwards
- initialRange = @selection.getRangeAt(0).cloneRange()
- for [0...count]
- unless window.find query, Utils.hasUpperCase(query), findBackwards, true, false, true, false
- @setSelectionRange initialRange
- HUD.showForDuration("No matches for '" + query + "'", 1000)
- return
- # The find was successfull. If we're in caret mode, then we should now have a selection, so we can
- # drop back into visual mode.
- @changeMode VisualMode if @name == "caret" and 0 < @selection.toString().length
-
- @movements.n = (count) -> executeFind count, false
- @movements.N = (count) -> executeFind count, true
+ doFind = (count, backwards) =>
+ initialRange = @selection.getRangeAt(0).cloneRange()
+ for [0...count] by 1
+ unless FindMode.execute null, {colorSelection: false, backwards}
+ @setSelectionRange initialRange
+ HUD.showForDuration("No matches for '#{FindMode.query.rawQuery}'", 1000)
+ return
+ # The find was successfull. If we're in caret mode, then we should now have a selection, so we can
+ # drop back into visual mode.
+ @changeMode VisualMode if @name == "caret" and 0 < @selection.toString().length
+
+ @movements.n = (count) -> doFind count, false
+ @movements.N = (count) -> doFind count, true
@movements["/"] = ->
- @findMode = window.enterFindMode returnToViewport: true
+ @findMode = new FindMode returnToViewport: true
@findMode.onExit => @changeMode VisualMode
#
# End of Movement constructor.
diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css
index 38a968fc..e02df7c2 100644
--- a/content_scripts/vimium.css
+++ b/content_scripts/vimium.css
@@ -284,6 +284,22 @@ iframe.vimiumHUDFrame {
opacity: 0;
}
+div.vimiumHUD span#hud-find-input, div.vimiumHUD span#hud-match-count {
+ display: inline;
+ outline: none;
+ white-space: nowrap;
+ overflow-y: hidden;
+}
+
+div.vimiumHUD span#hud-find-input br {
+ display: none;
+}
+
+div.vimiumHUD span#hud-find-input * {
+ display: inline;
+ white-space: nowrap;
+}
+
body.vimiumFindMode ::selection {
background: #ff9632;
}
diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee
index bffbd457..8f4c7e82 100644
--- a/content_scripts/vimium_frontend.coffee
+++ b/content_scripts/vimium_frontend.coffee
@@ -5,10 +5,6 @@
# "domReady".
#
-findModeQuery = { rawQuery: "", matchCount: 0 }
-findModeQueryHasResults = false
-findModeAnchorNode = null
-findModeInitialRange = null
isShowingHelpDialog = false
keyPort = null
isEnabledForUrl = true
@@ -622,108 +618,7 @@ window.refreshCompletionKeys = (response) ->
isValidFirstKey = (keyChar) ->
validFirstKeys[keyChar] || /^[1-9]/.test(keyChar)
-# This implements find-mode query history (using the "findModeRawQueryList" setting) as a list of raw queries,
-# most recent first.
-FindModeHistory =
- storage: chrome.storage.local
- key: "findModeRawQueryList"
- max: 50
- rawQueryList: null
-
- init: ->
- unless @rawQueryList
- @rawQueryList = [] # Prevent repeated initialization.
- @key = "findModeRawQueryListIncognito" if isIncognitoMode
- @storage.get @key, (items) =>
- unless chrome.runtime.lastError
- @rawQueryList = items[@key] if items[@key]
- if isIncognitoMode and not items[@key]
- # This is the first incognito tab, so we need to initialize the incognito-mode query history.
- @storage.get "findModeRawQueryList", (items) =>
- unless chrome.runtime.lastError
- @rawQueryList = items.findModeRawQueryList
- @storage.set findModeRawQueryListIncognito: @rawQueryList
-
- chrome.storage.onChanged.addListener (changes, area) =>
- @rawQueryList = changes[@key].newValue if changes[@key]
-
- getQuery: (index = 0) ->
- @rawQueryList[index] or ""
-
- saveQuery: (query) ->
- if 0 < query.length
- @rawQueryList = @refreshRawQueryList query, @rawQueryList
- newSetting = {}; newSetting[@key] = @rawQueryList
- @storage.set newSetting
- # If there are any active incognito-mode tabs, then propagte this query to those tabs too.
- unless isIncognitoMode
- @storage.get "findModeRawQueryListIncognito", (items) =>
- if not chrome.runtime.lastError and items.findModeRawQueryListIncognito
- @storage.set
- findModeRawQueryListIncognito: @refreshRawQueryList query, items.findModeRawQueryListIncognito
-
- refreshRawQueryList: (query, rawQueryList) ->
- ([ query ].concat rawQueryList.filter (q) => q != query)[0..@max]
-
-# should be called whenever rawQuery is modified.
-updateFindModeQuery = ->
- # the query can be treated differently (e.g. as a plain string versus regex depending on the presence of
- # escape sequences. '\' is the escape character and needs to be escaped itself to be used as a normal
- # character. here we grep for the relevant escape sequences.
- findModeQuery.isRegex = Settings.get 'regexFindMode'
- hasNoIgnoreCaseFlag = false
- findModeQuery.parsedQuery = findModeQuery.rawQuery.replace /(\\{1,2})([rRI]?)/g, (match, slashes, flag) ->
- return match if flag == "" or slashes.length != 1
- switch (flag)
- when "r"
- findModeQuery.isRegex = true
- when "R"
- findModeQuery.isRegex = false
- when "I"
- hasNoIgnoreCaseFlag = true
- ""
-
- # default to 'smartcase' mode, unless noIgnoreCase is explicitly specified
- findModeQuery.ignoreCase = !hasNoIgnoreCaseFlag && !Utils.hasUpperCase(findModeQuery.parsedQuery)
-
- # Don't count matches in the HUD.
- HUD.hide(true)
-
- # if we are dealing with a regex, grep for all matches in the text, and then call window.find() on them
- # sequentially so the browser handles the scrolling / text selection.
- if findModeQuery.isRegex
- try
- pattern = new RegExp(findModeQuery.parsedQuery, "g" + (if findModeQuery.ignoreCase then "i" else ""))
- catch error
- # if we catch a SyntaxError, assume the user is not done typing yet and return quietly
- return
- # innerText will not return the text of hidden elements, and strip out tags while preserving newlines
- text = document.body.innerText
- findModeQuery.regexMatches = text.match(pattern)
- findModeQuery.activeRegexIndex = 0
- findModeQuery.matchCount = findModeQuery.regexMatches?.length
- # if we are doing a basic plain string match, we still want to grep for matches of the string, so we can
- # show a the number of results. We can grep on document.body.innerText, as it should be indistinguishable
- # from the internal representation used by window.find.
- else
- # escape all special characters, so RegExp just parses the string 'as is'.
- # Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
- escapeRegExp = /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g
- parsedNonRegexQuery = findModeQuery.parsedQuery.replace(escapeRegExp, (char) -> "\\" + char)
- pattern = new RegExp(parsedNonRegexQuery, "g" + (if findModeQuery.ignoreCase then "i" else ""))
- text = document.body.innerText
- findModeQuery.matchCount = text.match(pattern)?.length
-
-updateQueryForFindMode = (rawQuery) ->
- findModeQuery.rawQuery = rawQuery
- updateFindModeQuery()
- performFindInPlace()
- showFindModeHUDForQuery()
-
-handleKeyCharForFindMode = (keyChar) ->
- updateQueryForFindMode findModeQuery.rawQuery + keyChar
-
-handleEscapeForFindMode = ->
+window.handleEscapeForFindMode = ->
document.body.classList.remove("vimiumFindMode")
# removing the class does not re-color existing selections. we recreate the current selection so it reverts
# back to the default color.
@@ -734,158 +629,38 @@ handleEscapeForFindMode = ->
window.getSelection().addRange(range)
focusFoundLink() || selectFoundInputElement()
-# Return true if character deleted, false otherwise.
-handleDeleteForFindMode = ->
- if findModeQuery.rawQuery.length == 0
- HUD.hide()
- false
- else
- updateQueryForFindMode findModeQuery.rawQuery.substring(0, findModeQuery.rawQuery.length - 1)
- true
-
# <esc> sends us into insert mode if possible, but <cr> does not.
# <esc> corresponds approximately to 'nevermind, I have found it already' while <cr> means 'I want to save
# this query and do more searches with it'
-handleEnterForFindMode = ->
+window.handleEnterForFindMode = ->
focusFoundLink()
document.body.classList.add("vimiumFindMode")
- FindModeHistory.saveQuery findModeQuery.rawQuery
-
-class FindMode extends Mode
- constructor: (options = {}) ->
- @historyIndex = -1
- @partialQuery = ""
- if options.returnToViewport
- @scrollX = window.scrollX
- @scrollY = window.scrollY
- super
- name: "find"
- indicator: false
- exitOnEscape: true
- exitOnClick: true
-
- keydown: (event) =>
- window.scrollTo @scrollX, @scrollY if options.returnToViewport
- if event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey
- @exit() unless handleDeleteForFindMode()
- @suppressEvent
- else if event.keyCode == keyCodes.enter
- handleEnterForFindMode()
- @exit()
- @suppressEvent
- else if event.keyCode == keyCodes.upArrow
- if rawQuery = FindModeHistory.getQuery @historyIndex + 1
- @historyIndex += 1
- @partialQuery = findModeQuery.rawQuery if @historyIndex == 0
- updateQueryForFindMode rawQuery
- @suppressEvent
- else if event.keyCode == keyCodes.downArrow
- @historyIndex = Math.max -1, @historyIndex - 1
- rawQuery = if 0 <= @historyIndex then FindModeHistory.getQuery @historyIndex else @partialQuery
- updateQueryForFindMode rawQuery
- @suppressEvent
- else
- DomUtils.suppressPropagation(event)
- handlerStack.stopBubblingAndFalse
-
- keypress: (event) =>
- handlerStack.neverContinueBubbling =>
- if event.keyCode > 31
- keyChar = String.fromCharCode event.charCode
- handleKeyCharForFindMode keyChar if keyChar
-
- keyup: (event) => @suppressEvent
-
- exit: (event) ->
- super()
- handleEscapeForFindMode() if event?.type == "keydown" and KeyboardUtils.isEscape event
- handleEscapeForFindMode() if event?.type == "click"
- if findModeQueryHasResults and event?.type != "click"
- new PostFindMode
-
-performFindInPlace = ->
- # Restore the selection. That way, we're always searching forward from the same place, so we find the right
- # match as the user adds matching characters, or removes previously-matched characters. See #1434.
- findModeRestoreSelection()
- query = if findModeQuery.isRegex then getNextQueryFromRegexMatches(0) else findModeQuery.parsedQuery
- findModeQueryHasResults = executeFind(query, { caseSensitive: !findModeQuery.ignoreCase })
-
-# :options is an optional dict. valid parameters are 'caseSensitive' and 'backwards'.
-executeFind = (query, options) ->
- result = null
- options = options || {}
-
- document.body.classList.add("vimiumFindMode")
-
- # ignore the selectionchange event generated by find()
- document.removeEventListener("selectionchange",restoreDefaultSelectionHighlight, true)
- result = window.find(query, options.caseSensitive, options.backwards, true, false, true, false)
- setTimeout(
- -> document.addEventListener("selectionchange", restoreDefaultSelectionHighlight, true)
- 0)
-
- # We are either in normal mode ("n"), or find mode ("/"). We are not in insert mode. Nevertheless, if a
- # 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
- 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()
- findModeAnchorNode = document.getSelection().anchorNode
- result
-
-restoreDefaultSelectionHighlight = -> document.body.classList.remove("vimiumFindMode")
+ FindMode.saveQuery()
focusFoundLink = ->
- if (findModeQueryHasResults)
+ if (FindMode.query.hasResults)
link = getLinkFromSelection()
link.focus() if link
selectFoundInputElement = ->
- # if the found text is in an input element, getSelection().anchorNode will be null, so we use activeElement
- # instead. however, since the last focused element might not be the one currently pointed to by find (e.g.
- # the current one might be disabled and therefore unable to receive focus), we use the approximate
- # heuristic of checking that the last anchor node is an ancestor of our element.
- if (findModeQueryHasResults && document.activeElement &&
+ # Since the last focused element might not be the one currently pointed to by find (e.g. the current one
+ # might be disabled and therefore unable to receive focus), we use the approximate heuristic of checking
+ # that the last anchor node is an ancestor of our element.
+ findModeAnchorNode = document.getSelection().anchorNode
+ if (FindMode.query.hasResults && document.activeElement &&
DomUtils.isSelectable(document.activeElement) &&
DomUtils.isDOMDescendant(findModeAnchorNode, document.activeElement))
DomUtils.simulateSelect(document.activeElement)
-getNextQueryFromRegexMatches = (stepSize) ->
- # find()ing an empty query always returns false
- return "" unless findModeQuery.regexMatches
-
- totalMatches = findModeQuery.regexMatches.length
- findModeQuery.activeRegexIndex += stepSize + totalMatches
- findModeQuery.activeRegexIndex %= totalMatches
-
- findModeQuery.regexMatches[findModeQuery.activeRegexIndex]
-
-window.getFindModeQuery = (backwards) ->
- # check if the query has been changed by a script in another frame
- mostRecentQuery = FindModeHistory.getQuery()
- if (mostRecentQuery != findModeQuery.rawQuery)
- findModeQuery.rawQuery = mostRecentQuery
- updateFindModeQuery()
-
- if findModeQuery.isRegex
- getNextQueryFromRegexMatches(if backwards then -1 else 1)
- else
- findModeQuery.parsedQuery
-
findAndFocus = (backwards) ->
Marks.setPreviousPosition()
- query = getFindModeQuery backwards
-
- findModeQueryHasResults =
- executeFind(query, { backwards: backwards, caseSensitive: !findModeQuery.ignoreCase })
+ FindMode.query.hasResults = FindMode.execute null, {backwards}
- if findModeQueryHasResults
+ if FindMode.query.hasResults
focusFoundLink()
- new PostFindMode() if findModeQueryHasResults
+ new PostFindMode()
else
- HUD.showForDuration("No matches for '" + findModeQuery.rawQuery + "'", 1000)
+ HUD.showForDuration("No matches for '#{FindMode.query.rawQuery}'", 1000)
window.performFind = -> findAndFocus()
@@ -992,43 +767,10 @@ window.goNext = ->
nextStrings = nextPatterns.split(",").filter( (s) -> s.trim().length )
findAndFollowRel("next") || findAndFollowLink(nextStrings)
-showFindModeHUDForQuery = ->
- if findModeQuery.rawQuery and (findModeQueryHasResults || findModeQuery.parsedQuery.length == 0)
- plural = if findModeQuery.matchCount == 1 then "" else "es"
- HUD.show("/" + findModeQuery.rawQuery + " (" + findModeQuery.matchCount + " Match#{plural})")
- else if findModeQuery.rawQuery
- HUD.show("/" + findModeQuery.rawQuery + " (No Matches)")
- else
- HUD.show("/")
-
-getCurrentRange = ->
- selection = getSelection()
- if selection.type == "None"
- range = document.createRange()
- range.setStart document.body, 0
- range.setEnd document.body, 0
- range
- else
- selection.collapseToStart() if selection.type == "Range"
- selection.getRangeAt 0
-
-findModeSaveSelection = ->
- findModeInitialRange = getCurrentRange()
-
-findModeRestoreSelection = (range = findModeInitialRange) ->
- selection = getSelection()
- selection.removeAllRanges()
- selection.addRange range
-
# Enters find mode. Returns the new find-mode instance.
-window.enterFindMode = (options = {}) ->
+window.enterFindMode = ->
Marks.setPreviousPosition()
- # Save the selection, so performFindInPlace can restore it.
- findModeSaveSelection()
- findModeQuery = rawQuery: ""
- findMode = new FindMode options
- HUD.show "/"
- findMode
+ new FindMode()
window.showHelpDialog = (html, fid) ->
return if (isShowingHelpDialog || !document.body || fid != frameId)
diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee
index 7c47179c..9658df2b 100644
--- a/lib/dom_utils.coffee
+++ b/lib/dom_utils.coffee
@@ -326,6 +326,18 @@ DomUtils =
document.body.removeChild div
coordinates
+ getSelectionFocusElement: ->
+ sel = window.getSelection()
+ if not sel.focusNode?
+ null
+ else if sel.focusNode == sel.anchorNode and sel.focusOffset == sel.anchorOffset
+ # The selection either *is* an element, or is inside an opaque element (eg. <input>).
+ sel.focusNode.childNodes[sel.focusOffset]
+ else if sel.focusNode.nodeType != sel.focusNode.ELEMENT_NODE
+ sel.focusNode.parentElement
+ else
+ sel.focusNode
+
# 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
diff --git a/lib/find_mode_history.coffee b/lib/find_mode_history.coffee
new file mode 100644
index 00000000..ff660bd2
--- /dev/null
+++ b/lib/find_mode_history.coffee
@@ -0,0 +1,50 @@
+# NOTE(mrmr1993): This is under lib/ since it is used by both content scripts and iframes from pages/.
+# This implements find-mode query history (using the "findModeRawQueryList" setting) as a list of raw queries,
+# most recent first.
+FindModeHistory =
+ storage: chrome?.storage.local # Guard against chrome being undefined (in the HUD iframe).
+ key: "findModeRawQueryList"
+ max: 50
+ rawQueryList: null
+
+ init: ->
+ @isIncognitoMode = chrome?.extension.inIncognitoContext
+
+ return unless @isIncognitoMode? # chrome is undefined in the HUD iframe during tests, so we do nothing.
+
+ unless @rawQueryList
+ @rawQueryList = [] # Prevent repeated initialization.
+ @key = "findModeRawQueryListIncognito" if @isIncognitoMode
+ @storage.get @key, (items) =>
+ unless chrome.runtime.lastError
+ @rawQueryList = items[@key] if items[@key]
+ if @isIncognitoMode and not items[@key]
+ # This is the first incognito tab, so we need to initialize the incognito-mode query history.
+ @storage.get "findModeRawQueryList", (items) =>
+ unless chrome.runtime.lastError
+ @rawQueryList = items.findModeRawQueryList
+ @storage.set findModeRawQueryListIncognito: @rawQueryList
+
+ chrome.storage.onChanged.addListener (changes, area) =>
+ @rawQueryList = changes[@key].newValue if changes[@key]
+
+ getQuery: (index = 0) ->
+ @rawQueryList[index] or ""
+
+ saveQuery: (query) ->
+ if 0 < query.length
+ @rawQueryList = @refreshRawQueryList query, @rawQueryList
+ newSetting = {}; newSetting[@key] = @rawQueryList
+ @storage.set newSetting
+ # If there are any active incognito-mode tabs, then propagte this query to those tabs too.
+ unless @isIncognitoMode
+ @storage.get "findModeRawQueryListIncognito", (items) =>
+ if not chrome.runtime.lastError and items.findModeRawQueryListIncognito
+ @storage.set
+ findModeRawQueryListIncognito: @refreshRawQueryList query, items.findModeRawQueryListIncognito
+
+ refreshRawQueryList: (query, rawQueryList) ->
+ ([ query ].concat rawQueryList.filter (q) => q != query)[0..@max]
+
+root = exports ? window
+root.FindModeHistory = FindModeHistory
diff --git a/lib/settings.coffee b/lib/settings.coffee
index c1caecd3..99a20963 100644
--- a/lib/settings.coffee
+++ b/lib/settings.coffee
@@ -1,5 +1,17 @@
+# A "setting" is a stored key/value pair. An "option" is a setting which has a default value and whose value
+# can be changed on the options page.
+#
+# Option values which have never been changed by the user are in Settings.defaults.
+#
+# Settings whose values have been changed are:
+# 1. stored either in chrome.storage.sync or in chrome.storage.local (but never both), and
+# 2. cached in Settings.cache; on extension pages, Settings.cache uses localStorage (so it persists).
+#
+# In all cases except Settings.defaults, values are stored as jsonified strings.
+
Settings =
+ debug: false
storage: chrome.storage.sync
cache: {}
isLoaded: false
@@ -11,18 +23,21 @@ Settings =
@cache = if Utils.isBackgroundPage() then localStorage else extend {}, localStorage
@onLoaded()
- @storage.get null, (items) =>
- unless chrome.runtime.lastError
- @handleUpdateFromChromeStorage key, value for own key, value of items
+ chrome.storage.local.get null, (localItems) =>
+ localItems = {} if chrome.runtime.lastError
+ @storage.get null, (syncedItems) =>
+ unless chrome.runtime.lastError
+ @handleUpdateFromChromeStorage key, value for own key, value of extend localItems, syncedItems
- chrome.storage.onChanged.addListener (changes, area) =>
- @propagateChangesFromChromeStorage changes if area == "sync"
+ chrome.storage.onChanged.addListener (changes, area) =>
+ @propagateChangesFromChromeStorage changes if area == "sync"
- @onLoaded()
+ @onLoaded()
# Called after @cache has been initialized. On extension pages, this will be called twice, but that does
# not matter because it's idempotent.
onLoaded: ->
+ @log "onLoaded: #{@onLoadedCallbacks.length} callback(s)"
@isLoaded = true
callback() while callback = @onLoadedCallbacks.pop()
@@ -33,46 +48,40 @@ Settings =
@handleUpdateFromChromeStorage key, change?.newValue for own key, change of changes
handleUpdateFromChromeStorage: (key, value) ->
+ @log "handleUpdateFromChromeStorage: #{key}"
# Note: value here is either null or a JSONified string. Therefore, even falsy settings values (like
# false, 0 or "") are truthy here. Only null is falsy.
if @shouldSyncKey key
unless value and key of @cache and @cache[key] == value
- defaultValue = @defaults[key]
- defaultValueJSON = JSON.stringify defaultValue
-
- if value and value != defaultValueJSON
- # Key/value has been changed to a non-default value.
- @cache[key] = value
- @performPostUpdateHook key, JSON.parse value
- else
- # The key has been reset to its default value.
- delete @cache[key] if key of @cache
- @performPostUpdateHook key, defaultValue
+ value ?= JSON.stringify @defaults[key]
+ @set key, JSON.parse(value), false
get: (key) ->
console.log "WARNING: Settings have not loaded yet; using the default value for #{key}." unless @isLoaded
if key of @cache and @cache[key]? 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 JSON.stringify(value) == JSON.stringify @defaults[key]
- @clear key
- else
- jsonValue = JSON.stringify value
- @cache[key] = jsonValue
- if @shouldSyncKey key
- setting = {}; setting[key] = jsonValue
+ set: (key, value, shouldSetInSyncedStorage = true) ->
+ @cache[key] = JSON.stringify value
+ @log "set: #{key} (length=#{@cache[key].length}, shouldSetInSyncedStorage=#{shouldSetInSyncedStorage})"
+ if @shouldSyncKey key
+ if shouldSetInSyncedStorage
+ setting = {}; setting[key] = @cache[key]
+ @log " chrome.storage.sync.set(#{key})"
@storage.set setting
- @performPostUpdateHook key, value
+ if Utils.isBackgroundPage()
+ # Remove options installed by the "copyNonDefaultsToChromeStorage-20150717" migration; see below.
+ @log " chrome.storage.local.remove(#{key})"
+ chrome.storage.local.remove key
+ @performPostUpdateHook key, value
clear: (key) ->
- delete @cache[key] if @has key
- @storage.remove key if @shouldSyncKey key
- @performPostUpdateHook key, @get key
+ @log "clear: #{key}"
+ @set key, @defaults[key]
has: (key) -> key of @cache
use: (key, callback) ->
+ @log "use: #{key} (isLoaded=#{@isLoaded})"
invokeCallback = => callback @get key
if @isLoaded then invokeCallback() else @onLoadedCallbacks.push invokeCallback
@@ -80,6 +89,10 @@ Settings =
postUpdateHooks: {}
performPostUpdateHook: (key, value) -> @postUpdateHooks[key]? value
+ # For development only.
+ log: (args...) ->
+ console.log "settings:", args... if @debug
+
# Default values for all settings.
defaults:
scrollStepSize: 60
@@ -170,5 +183,19 @@ if Utils.isBackgroundPage()
rawQuery = Settings.get "findModeRawQuery"
chrome.storage.local.set findModeRawQueryList: (if rawQuery then [ rawQuery ] else [])
+ # Migration (after 1.51, 2015/6/17).
+ # Copy options with non-default values (and which are not in synced storage) to chrome.storage.local;
+ # thereby making these settings accessible within content scripts.
+ do (migrationKey = "copyNonDefaultsToChromeStorage-20150717") ->
+ unless localStorage[migrationKey]
+ chrome.storage.sync.get null, (items) ->
+ unless chrome.runtime.lastError
+ updates = {}
+ for own key of localStorage
+ if Settings.shouldSyncKey(key) and not items[key]
+ updates[key] = localStorage[key]
+ chrome.storage.local.set updates, ->
+ localStorage[migrationKey] = not chrome.runtime.lastError
+
root = exports ? window
root.Settings = Settings
diff --git a/manifest.json b/manifest.json
index 80aca4c5..4ef5edfe 100644
--- a/manifest.json
+++ b/manifest.json
@@ -42,6 +42,7 @@
"lib/handler_stack.js",
"lib/clipboard.js",
"lib/settings.js",
+ "lib/find_mode_history.js",
"content_scripts/ui_component.js",
"content_scripts/link_hints.js",
"content_scripts/vomnibar.js",
diff --git a/pages/hud.coffee b/pages/hud.coffee
index 68283451..37debc4e 100644
--- a/pages/hud.coffee
+++ b/pages/hud.coffee
@@ -1,3 +1,49 @@
+findMode = null
+
+# Set the input element's text, and move the cursor to the end.
+setTextInInputElement = (inputElement, text) ->
+ inputElement.textContent = text
+ # Move the cursor to the end. Based on one of the solutions here:
+ # http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity
+ range = document.createRange()
+ range.selectNodeContents inputElement
+ range.collapse false
+ selection = window.getSelection()
+ selection.removeAllRanges()
+ selection.addRange range
+
+document.addEventListener "keydown", (event) ->
+ inputElement = document.getElementById "hud-find-input"
+ return unless inputElement? # Don't do anything if we're not in find mode.
+ transferrableEvent = {}
+ for key, value of event
+ transferrableEvent[key] = value if typeof value in ["number", "string"]
+
+ if (event.keyCode in [keyCodes.backspace, keyCodes.deleteKey] and inputElement.textContent.length == 0) or
+ event.keyCode == keyCodes.enter or KeyboardUtils.isEscape event
+
+ UIComponentServer.postMessage
+ name: "hideFindMode"
+ event: transferrableEvent
+ query: findMode.rawQuery
+
+ else if event.keyCode == keyCodes.upArrow
+ if rawQuery = FindModeHistory.getQuery findMode.historyIndex + 1
+ findMode.historyIndex += 1
+ findMode.partialQuery = findMode.rawQuery if findMode.historyIndex == 0
+ setTextInInputElement inputElement, rawQuery
+ findMode.executeQuery()
+ else if event.keyCode == keyCodes.downArrow
+ findMode.historyIndex = Math.max -1, findMode.historyIndex - 1
+ rawQuery = if 0 <= findMode.historyIndex then FindModeHistory.getQuery findMode.historyIndex else findMode.partialQuery
+ setTextInInputElement inputElement, rawQuery
+ findMode.executeQuery()
+ else
+ return
+
+ DomUtils.suppressEvent event
+ false
+
handlers =
show: (data) ->
document.getElementById("hud").innerText = data.text
@@ -10,6 +56,47 @@ handlers =
document.getElementById("hud").classList.add "vimiumUIComponentHidden"
document.getElementById("hud").classList.remove "vimiumUIComponentVisible"
+ showFindMode: (data) ->
+ hud = document.getElementById "hud"
+ hud.innerText = "/\u200A" # \u200A is a "hair space", to leave enough space before the caret/first char.
+
+ inputElement = document.createElement "span"
+ inputElement.contentEditable = "plaintext-only"
+ setTextInInputElement inputElement, data.text ? ""
+ inputElement.id = "hud-find-input"
+ hud.appendChild inputElement
+
+ inputElement.addEventListener "input", executeQuery = (event) ->
+ # Replace \u00A0 (&nbsp;) with a normal space.
+ findMode.rawQuery = inputElement.textContent.replace "\u00A0", " "
+ UIComponentServer.postMessage {name: "search", query: findMode.rawQuery}
+
+ countElement = document.createElement "span"
+ countElement.id = "hud-match-count"
+ hud.appendChild countElement
+ inputElement.focus()
+
+ # Replace \u00A0 (&nbsp;) with a normal space.
+ UIComponentServer.postMessage {name: "search", query: inputElement.textContent.replace "\u00A0", " "}
+
+ findMode =
+ historyIndex: -1
+ partialQuery: ""
+ rawQuery: ""
+ executeQuery: executeQuery
+
+ updateMatchesCount: ({matchCount, showMatchText}) ->
+ countElement = document.getElementById "hud-match-count"
+ return unless countElement? # Don't do anything if we're not in find mode.
+
+ countText = if matchCount > 0
+ " (#{matchCount} Match#{if matchCount == 1 then "" else "es"})"
+ else
+ " (No matches)"
+ countElement.textContent = if showMatchText then countText else ""
+
UIComponentServer.registerHandler (event) ->
{data} = event
handlers[data.name]? data
+
+FindModeHistory.init()
diff --git a/pages/hud.html b/pages/hud.html
index bcb38e04..60d737e1 100644
--- a/pages/hud.html
+++ b/pages/hud.html
@@ -2,6 +2,9 @@
<head>
<title>HUD</title>
<link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" />
+ <script type="text/javascript" src="../lib/dom_utils.js"></script>
+ <script type="text/javascript" src="../lib/keyboard_utils.js"></script>
+ <script type="text/javascript" src="../lib/find_mode_history.js"></script>
<script type="text/javascript" src="ui_component_server.js"></script>
<script type="text/javascript" src="hud.js"></script>
</head>
diff --git a/tests/dom_tests/dom_tests.html b/tests/dom_tests/dom_tests.html
index f7cc430d..25c5f8ba 100644
--- a/tests/dom_tests/dom_tests.html
+++ b/tests/dom_tests/dom_tests.html
@@ -36,6 +36,7 @@
<script type="text/javascript" src="../../lib/handler_stack.js"></script>
<script type="text/javascript" src="../../lib/clipboard.js"></script>
<script type="text/javascript" src="../../lib/settings.js"></script>
+ <script type="text/javascript" src="../../lib/find_mode_history.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>
diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee
index 47abe97f..9dc25cef 100644
--- a/tests/unit_tests/settings_test.coffee
+++ b/tests/unit_tests/settings_test.coffee
@@ -28,12 +28,6 @@ context "settings",
Settings.set 'scrollStepSize', 20
assert.equal Settings.get('scrollStepSize'), 20
- should "not store values equal to the default", ->
- Settings.set 'scrollStepSize', 20
- assert.isTrue Settings.has 'scrollStepSize'
- Settings.set 'scrollStepSize', 60
- assert.isFalse Settings.has 'scrollStepSize'
-
should "revert to defaults if no key is stored", ->
Settings.set 'scrollStepSize', 20
Settings.clear 'scrollStepSize'
@@ -56,7 +50,7 @@ context "synced settings",
Settings.set 'scrollStepSize', 20
assert.equal Settings.get('scrollStepSize'), 20
Settings.propagateChangesFromChromeStorage { scrollStepSize: { newValue: "60" } }
- assert.isFalse Settings.has 'scrollStepSize'
+ assert.equal Settings.get('scrollStepSize'), 60
should "propagate non-default values from synced storage", ->
chrome.storage.sync.set { scrollStepSize: JSON.stringify(20) }
@@ -65,12 +59,12 @@ context "synced settings",
should "propagate default values from synced storage", ->
Settings.set 'scrollStepSize', 20
chrome.storage.sync.set { scrollStepSize: JSON.stringify(60) }
- assert.isFalse Settings.has 'scrollStepSize'
+ assert.equal Settings.get('scrollStepSize'), 60
should "clear a setting from synced storage", ->
Settings.set 'scrollStepSize', 20
chrome.storage.sync.remove 'scrollStepSize'
- assert.isFalse Settings.has 'scrollStepSize'
+ assert.equal Settings.get('scrollStepSize'), 60
should "trigger a postUpdateHook", ->
message = "Hello World"
diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee
index 8a677990..0bb6ed81 100644
--- a/tests/unit_tests/test_chrome_stubs.coffee
+++ b/tests/unit_tests/test_chrome_stubs.coffee
@@ -62,9 +62,9 @@ exports.chrome =
storage:
# chrome.storage.local
local:
- get: ->
- set: ->
- remove: ->
+ get: (_, callback) -> callback?()
+ set: (_, callback) -> callback?()
+ remove: (_, callback) -> callback?()
# chrome.storage.onChanged
onChanged: