diff options
| -rw-r--r-- | content_scripts/hud.coffee | 40 | ||||
| -rw-r--r-- | content_scripts/mode_find.coffee | 156 | ||||
| -rw-r--r-- | content_scripts/mode_visual_edit.coffee | 29 | ||||
| -rw-r--r-- | content_scripts/vimium.css | 16 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 288 | ||||
| -rw-r--r-- | lib/dom_utils.coffee | 12 | ||||
| -rw-r--r-- | lib/find_mode_history.coffee | 50 | ||||
| -rw-r--r-- | lib/settings.coffee | 87 | ||||
| -rw-r--r-- | manifest.json | 1 | ||||
| -rw-r--r-- | pages/hud.coffee | 87 | ||||
| -rw-r--r-- | pages/hud.html | 3 | ||||
| -rw-r--r-- | tests/dom_tests/dom_tests.html | 1 | ||||
| -rw-r--r-- | tests/unit_tests/settings_test.coffee | 12 | ||||
| -rw-r--r-- | tests/unit_tests/test_chrome_stubs.coffee | 6 |
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 ( ) 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 ( ) 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: |
