diff options
45 files changed, 866 insertions, 606 deletions
| diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61c32456..e4ed8b8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,9 +11,16 @@ respective issue so others don't duplicate your effort.  Please include the following when reporting an issue: - 1. Chrome and OS Version: `chrome://version` +### Chrome/Chromium + + 1. Chrome/Chromium and OS Version: `chrome://version`   1. Vimium Version: `chrome://extensions` +### Firefox + + 1. Firefox and OS Version: `about:` + 1. Vimium Version: `about:addons`, then click on `More` below Vimium +  ## Installing From Source  Vimium is written in Coffeescript, which compiles to Javascript. To @@ -77,6 +77,9 @@ task "package", "Builds a zip file for submission to the Chrome store. The outpu    spawn "rsync", rsyncOptions, false, true    spawn "zip", ["-r", "dist/vimium-#{vimium_version}.zip", "dist/vimium"], false, true +  spawn "zip", "-r -FS dist/vimium-ff-#{vimium_version}.zip background_scripts Cakefile content_scripts CONTRIBUTING.md CREDITS icons lib +                manifest.json MIT-LICENSE.txt pages README.md -x *.coffee -x Cakefile -x CREDITS -x *.md".split(/\s+/), false, true +  # This builds a CRX that's distributable outside of the Chrome web store. Is this used by folks who fork  # Vimium and want to distribute their fork?  task "package-custom-crx", "build .crx file", -> @@ -157,8 +160,3 @@ task "coverage", "generate coverage report", ->            source: (Utils.escapeHtml fs.readFileSync fname, 'utf-8').split '\n'        fs.writeFileSync 'jscoverage.json', JSON.stringify(result) - -task "zip", "build Firefox zip file in ../vimium.zip", -> -  spawn "zip", "-r -FS ../vimium.zip background_scripts Cakefile content_scripts CONTRIBUTING.md CREDITS icons lib -                manifest.json MIT-LICENSE.txt pages README.md -x *.coffee -x Cakefile -x CREDITS -x *.md".split /\s+/ - @@ -141,9 +141,10 @@ The following special keys are available for mapping:  - `<c-*>`, `<a-*>`, `<m-*>` for ctrl, alt, and meta (command on Mac) respectively with any key. Replace `*`    with the key of choice. -- `<left>`, `<right>`, `<up>`, `<down>` for the arrow keys -- `<space>` and `<backspace>` for the space and backspace keys -- `<f1>` through `<f12>` for the function keys +- `<left>`, `<right>`, `<up>`, `<down>` for the arrow keys. +- `<f1>` through `<f12>` for the function keys. +- `<space>` for the space key. +- `<tab>`, `<enter>`, `<delete>`, `<backspace>`, `<insert>`, `<home>` and `<end>` for the corresponding non-printable keys (version 1.62 onwards).  Shifts are automatically detected so, for example, `<c-&>` corresponds to ctrl+shift+7 on an English keyboard. @@ -168,6 +169,26 @@ PRs are welcome.  Release Notes  ------------- +In `master` (not yet released) + +- Backup and restore Vimium options (see the very bottom of the options page, below *Advanced Options*). +- It is now possible to map `<tab>`, `<enter>`, `<delete>`, `<insert>`, `<home>` and `<end>`. +- New command options for `createTab` to create create new normal and incognito windows +  ([examples](https://github.com/philc/vimium/wiki/Tips-and-Tricks#creating-tabs-with-urls-and-windows)). +- When upgrading, you will be asked to re-validate permissions.  The only new +  permission is "copy and paste to/from clipboard" (the `clipboardWrite` +  permission).  This is necessary to support copy/paste on Firefox. + +1.61 (2017-10-27) + +- For *filtered hints*, you can now use alphabetical hint characters +  instead of digits; use `<Shift>` for hint characters. +- With `map R reload hard`, the reload command now asks Chrome to bypass its cache. +- You can now map `<c-[>` to a command (in which case it will not be treated as `Escape`). +- Various bug fixes, particularly for Firefox. +- Minor versions: +    - 1.61.1: Fix `map R reload hard`. +  1.60 (2017-09-14)  - Features: diff --git a/background_scripts/bg_utils.coffee b/background_scripts/bg_utils.coffee index b8e618ff..698f5352 100644 --- a/background_scripts/bg_utils.coffee +++ b/background_scripts/bg_utils.coffee @@ -18,7 +18,7 @@ class TabRecency        @deregister removedTabId        @register addedTabId -    chrome.windows.onFocusChanged.addListener (wnd) => +    chrome.windows?.onFocusChanged.addListener (wnd) =>        if wnd != chrome.windows.WINDOW_ID_NONE          chrome.tabs.query {windowId: wnd, active: true}, (tabs) =>            @register tabs[0].id if tabs[0] diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index cda036e6..4d2e1606 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -113,6 +113,9 @@ Commands =            # We don't need these properties in the content scripts.            delete currentMapping[key][prop] for prop in ["keySequence", "description"]      chrome.storage.local.set normalModeKeyStateMapping: keyStateMapping +    # Inform `KeyboardUtils.isEscape()` whether `<c-[>` should be interpreted as `Escape` (which it is by +    # default). +    chrome.storage.local.set useVimLikeEscape: "<c-[>" not of keyStateMapping    # Build the "helpPageData" data structure which the help page needs and place it in Chrome storage.    prepareHelpPageData: -> @@ -337,8 +340,8 @@ commandDescriptions =    toggleViewSource: ["View page source", { noRepeat: true }]    copyCurrentUrl: ["Copy the current URL to the clipboard", { noRepeat: true }] -  openCopiedUrlInCurrentTab: ["Open the clipboard's URL in the current tab", { background: true, noRepeat: true }] -  openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true, repeatLimit: 20 }] +  openCopiedUrlInCurrentTab: ["Open the clipboard's URL in the current tab", { noRepeat: true }] +  openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { repeatLimit: 20 }]    enterInsertMode: ["Enter insert mode", { noRepeat: true }]    passNextKey: ["Pass the next key to the page"] diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index 97d8fa65..8220545d 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -114,7 +114,7 @@ TabOperations =      canUseOpenerTabId = not (Utils.isFirefox() and Utils.compareVersions(Utils.firefoxVersion(), "57") < 0)      tabConfig.openerTabId = request.tab.id if canUseOpenerTabId -    chrome.tabs.create tabConfig, callback +    chrome.tabs.create tabConfig, -> callback request    # Opens request.url in new window and switches to it.    openUrlInNewWindow: (request, callback = (->)) -> @@ -148,7 +148,7 @@ toggleMuteTab = do ->  #  selectSpecificTab = (request) ->    chrome.tabs.get(request.id, (tab) -> -    chrome.windows.update(tab.windowId, { focused: true }) +    chrome.windows?.update(tab.windowId, { focused: true })      chrome.tabs.update(request.id, { active: true }))  moveTab = ({count, tab, registryEntry}) -> @@ -188,13 +188,20 @@ BackgroundCommands =              [if request.tab.incognito then "chrome://newtab" else chrome.runtime.getURL newTabUrl]            else              [newTabUrl] -    urls = request.urls[..].reverse() -    do openNextUrl = (request) -> -      if 0 < urls.length -        TabOperations.openUrlInNewTab (extend request, {url: urls.pop()}), (tab) -> -          openNextUrl extend request, {tab, tabId: tab.id} -      else -        callback request +    if request.registryEntry.options.incognito or request.registryEntry.options.window +      windowConfig = +        url: request.urls +        focused: true +        incognito: request.registryEntry.options.incognito ? false +      chrome.windows.create windowConfig, -> callback request +    else +      urls = request.urls[..].reverse() +      do openNextUrl = (request) -> +        if 0 < urls.length +          TabOperations.openUrlInNewTab (extend request, {url: urls.pop()}), (tab) -> +            openNextUrl extend request, {tab, tabId: tab.id} +        else +          callback request    duplicateTab: mkRepeatCommand (request, callback) ->      chrome.tabs.duplicate request.tabId, (tab) -> callback extend request, {tab, tabId: tab.id}    moveTabToNewWindow: ({count, tab}) -> @@ -214,8 +221,6 @@ BackgroundCommands =        startTabIndex = Math.max 0, Math.min activeTabIndex, tabs.length - count        chrome.tabs.remove (tab.id for tab in tabs[startTabIndex...startTabIndex + count])    restoreTab: mkRepeatCommand (request, callback) -> chrome.sessions.restore null, callback request -  openCopiedUrlInCurrentTab: (request) -> TabOperations.openUrlInCurrentTab extend request, url: Clipboard.paste() -  openCopiedUrlInNewTab: (request) -> @createTab extend request, url: Clipboard.paste()    togglePinTab: ({tab}) -> chrome.tabs.update tab.id, {pinned: !tab.pinned}    toggleMuteTab: toggleMuteTab    moveTabLeft: moveTab @@ -324,7 +329,7 @@ Frames =      enabledState = Exclusions.isEnabledForUrl request.url      if request.frameIsFocused -      chrome.browserAction.setIcon tabId: tabId, imageData: do -> +      chrome.browserAction.setIcon? tabId: tabId, imageData: do ->          enabledStateIcon =            if not enabledState.isEnabledForUrl              DISABLED_ICON @@ -355,7 +360,7 @@ handleFrameFocused = ({tabId, frameId}) ->  # Rotate through frames to the frame count places after frameId.  cycleToFrame = (frames, frameId, count = 0) -> -  # We can't always track which frame chrome has focussed, but here we learn that it's frameId; so add an +  # We can't always track which frame chrome has focused, but here we learn that it's frameId; so add an    # additional offset such that we do indeed start from frameId.    count = (count + Math.max 0, frames.indexOf frameId) % frames.length    [frames[count..]..., frames[0...count]...] @@ -423,7 +428,7 @@ sendRequestHandlers =    # getCurrentTabUrl is used by the content scripts to get their full URL, because window.location cannot help    # with Chrome-specific URLs like "view-source:http:..".    getCurrentTabUrl: ({tab}) -> tab.url -  openUrlInNewTab: (request) -> TabOperations.openUrlInNewTab request +  openUrlInNewTab: mkRepeatCommand (request, callback) -> TabOperations.openUrlInNewTab request, callback    openUrlInNewWindow: (request) -> TabOperations.openUrlInNewWindow request    openUrlInIncognito: (request) -> chrome.windows.create incognito: true, url: Utils.convertToUrl request.url    openUrlInCurrentTab: TabOperations.openUrlInCurrentTab @@ -431,8 +436,6 @@ sendRequestHandlers =      chrome.tabs.create url: chrome.runtime.getURL("pages/options.html"), index: request.tab.index + 1    frameFocused: handleFrameFocused    nextFrame: BackgroundCommands.nextFrame -  copyToClipboard: Clipboard.copy.bind Clipboard -  pasteFromClipboard: Clipboard.paste.bind Clipboard    selectSpecificTab: selectSpecificTab    createMark: Marks.create.bind(Marks)    gotoMark: Marks.goto.bind(Marks) @@ -449,7 +452,7 @@ chrome.tabs.onRemoved.addListener (tabId) ->    delete cache[tabId] for cache in [frameIdsForTab, urlForTab, portsForTab, HintCoordinator.tabState]    chrome.storage.local.get "findModeRawQueryListIncognito", (items) ->      if items.findModeRawQueryListIncognito -      chrome.windows.getAll null, (windows) -> +      chrome.windows?.getAll null, (windows) ->          for window in windows            return if window.incognito          # There are no remaining incognito-mode tabs, and findModeRawQueryListIncognito is set. diff --git a/background_scripts/marks.coffee b/background_scripts/marks.coffee index a6491b9e..77b07b41 100644 --- a/background_scripts/marks.coffee +++ b/background_scripts/marks.coffee @@ -82,7 +82,7 @@ Marks =    # Given a list of tabs candidate tabs, pick one.  Prefer tabs in the current window and tabs with shorter    # (matching) URLs.    pickTab: (tabs, callback) -> -    chrome.windows.getCurrent ({ id }) -> +    tabPicker = ({ id }) ->        # Prefer tabs in the current window, if there are any.        tabsInWindow = tabs.filter (tab) -> tab.windowId == id        tabs = tabsInWindow if 0 < tabsInWindow.length @@ -92,6 +92,10 @@ Marks =        # Prefer shorter URLs.        tabs.sort (a,b) -> a.url.length - b.url.length        callback tabs[0] +    if chrome.windows? +      chrome.windows.getCurrent tabPicker +    else +      tabPicker({id: undefined})  root = exports ? window  root.Marks = Marks diff --git a/content_scripts/hud.coffee b/content_scripts/hud.coffee index c2170914..42a960da 100644 --- a/content_scripts/hud.coffee +++ b/content_scripts/hud.coffee @@ -9,6 +9,8 @@ HUD =    findMode: null    abandon: -> @hudUI?.hide false +  pasteListener: null # Set by @pasteFromClipboard to handle the value returned by pasteResponse +    # 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    # it doesn't sit on top of horizontal scrollbars like Chrome's HUD does. @@ -71,17 +73,44 @@ HUD =      focusNode?.focus?()      if exitEventIsEnter -      handleEnterForFindMode() +      FindMode.handleEnter()        if FindMode.query.hasResults          postExit = -> new PostFindMode      else if exitEventIsEscape -      # We don't want FindMode to handle the click events that handleEscapeForFindMode can generate, so we +      # We don't want FindMode to handle the click events that FindMode.handleEscape can generate, so we        # wait until the mode is closed before running it. -      postExit = handleEscapeForFindMode +      postExit = FindMode.handleEscape      @findMode.exit()      postExit?() +  # These commands manage copying and pasting from the clipboard in the HUD frame. +  # NOTE(mrmr1993): We need this to copy and paste on Firefox: +  # * an element can't be focused in the background page, so copying/pasting doesn't work +  # * we don't want to disrupt the focus in the page, in case the page is listening for focus/blur events. +  # * the HUD shouldn't be active for this frame while any of the copy/paste commands are running. +  copyToClipboard: (text) -> +    DomUtils.documentComplete => +      @init() +      @hudUI?.postMessage {name: "copyToClipboard", data: text} + +  pasteFromClipboard: (@pasteListener) -> +    DomUtils.documentComplete => +      @init() +      # Show the HUD frame, so Firefox will actually perform the paste. +      @hudUI.toggleIframeElementClasses "vimiumUIComponentHidden", "vimiumUIComponentVisible" +      @tween.fade 0, 0 +      @hudUI.postMessage {name: "pasteFromClipboard"} + +  pasteResponse: ({data}) -> +    # Hide the HUD frame again. +    @hudUI.toggleIframeElementClasses "vimiumUIComponentVisible", "vimiumUIComponentHidden" +    @unfocusIfFocused() +    @pasteListener data + +  unfocusIfFocused: -> +    document.activeElement.blur() if document.activeElement == @hudUI?.iframeElement +  class Tween    opacity: 0    intervalId: -1 @@ -127,5 +156,6 @@ class Tween        }      """ -root = exports ? window +root = exports ? (window.root ?= {})  root.HUD = HUD +extend window, root unless exports? diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee index d9ce5d06..d4a95d36 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -31,7 +31,7 @@ COPY_LINK_URL =    indicator: "Copy link URL to Clipboard"    linkActivator: (link) ->      if link.href? -      chrome.runtime.sendMessage handler: "copyToClipboard", data: link.href +      HUD.copyToClipboard link.href        url = link.href        url = url[0..25] + "...." if 28 < url.length        HUD.showForDuration "Yanked #{url}", 2000 @@ -166,7 +166,6 @@ class LinkHintsMode        name: "hint/#{@mode.name}"        indicator: false        singleton: "link-hints-mode" -      passInitialKeyupEvents: true        suppressAllKeyboardEvents: true        suppressTrailingKeyEvents: true        exitOnEscape: true @@ -233,13 +232,9 @@ class LinkHintsMode    onKeyDownInMode: (event) ->      return if event.repeat -    previousTabCount = @tabCount -    @tabCount = 0 -      # NOTE(smblott) The modifier behaviour here applies only to alphabet hints.      if event.key in ["Control", "Shift"] and not Settings.get("filterLinkHints") and        @mode in [ OPEN_IN_CURRENT_TAB, OPEN_WITH_QUEUE, OPEN_IN_NEW_BG_TAB, OPEN_IN_NEW_FG_TAB ] -        @tabCount = previousTabCount          # Toggle whether to open the link in a new or current tab.          previousMode = @mode          key = event.key @@ -250,19 +245,16 @@ class LinkHintsMode            when "Control"              @setOpenLinkMode(if @mode is OPEN_IN_NEW_FG_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_NEW_FG_TAB) -        handlerId = handlerStack.push +        handlerId = @hintMode.push            keyup: (event) =>              if event.key == key                handlerStack.remove()                @setOpenLinkMode previousMode              true # Continue bubbling the event. -        # For some (unknown) reason, we don't always receive the keyup event needed to remove this handler. -        # Therefore, we ensure that it's always removed when hint mode exits.  See #1911 and #1926. -        @hintMode.onExit -> handlerStack.remove handlerId -      else if KeyboardUtils.isBackspace event        if @markerMatcher.popKeyChar() +        @tabCount = 0          @updateVisibleMarkers()        else          # Exit via @hintMode.exit(), so that the LinkHints.activate() "onExit" callback sees the key event and @@ -274,15 +266,13 @@ class LinkHintsMode        HintCoordinator.sendMessage "activateActiveHintMarker" if @markerMatcher.activeHintMarker      else if event.key == "Tab" -      @tabCount = previousTabCount + (if event.shiftKey then -1 else 1) -      @updateVisibleMarkers @tabCount +      if event.shiftKey then @tabCount-- else @tabCount++ +      @updateVisibleMarkers()      else if event.key == " " and @markerMatcher.shouldRotateHints event -      @tabCount = previousTabCount        HintCoordinator.sendMessage "rotateHints"      else -      @tabCount = previousTabCount if event.ctrlKey or event.metaKey or event.altKey        unless event.repeat          keyChar =            if Settings.get "filterLinkHints" @@ -292,17 +282,18 @@ class LinkHintsMode          if keyChar            keyChar = " " if keyChar == "space"            if keyChar.length == 1 +            @tabCount = 0              @markerMatcher.pushKeyChar keyChar              @updateVisibleMarkers() -          DomUtils.consumeKeyup event -      return +          else +            return handlerStack.suppressPropagation -    # We've handled the event, so suppress it and update the mode indicator. -    DomUtils.suppressEvent event +    handlerStack.suppressEvent -  updateVisibleMarkers: (tabCount = 0) -> +  updateVisibleMarkers: ->      {hintKeystrokeQueue, linkTextKeystrokeQueue} = @markerMatcher -    HintCoordinator.sendMessage "updateKeyState", {hintKeystrokeQueue, linkTextKeystrokeQueue, tabCount} +    HintCoordinator.sendMessage "updateKeyState", +      {hintKeystrokeQueue, linkTextKeystrokeQueue, tabCount: @tabCount}    updateKeyState: ({hintKeystrokeQueue, linkTextKeystrokeQueue, tabCount}) ->      extend @markerMatcher, {hintKeystrokeQueue, linkTextKeystrokeQueue} @@ -311,7 +302,7 @@ class LinkHintsMode      if linksMatched.length == 0        @deactivateMode()      else if linksMatched.length == 1 -      @activateLink linksMatched[0], userMightOverType ? false +      @activateLink linksMatched[0], userMightOverType      else        @hideMarker marker for marker in @hintMarkers        @showMarker matched, @markerMatcher.hintKeystrokeQueue.length for matched in linksMatched @@ -322,7 +313,7 @@ class LinkHintsMode    rotateHints: do ->      markerOverlapsStack = (marker, stack) ->        for otherMarker in stack -        return true if Rect.rectsOverlap marker.markerRect, otherMarker.markerRect +        return true if Rect.intersects marker.markerRect, otherMarker.markerRect        false      -> @@ -365,7 +356,7 @@ class LinkHintsMode    # When only one hint remains, activate it in the appropriate way.  The current frame may or may not contain    # the matched link, and may or may not have the focus.  The resulting four cases are accounted for here by    # selectively pushing the appropriate HintCoordinator.onExit handlers. -  activateLink: (linkMatched, userMightOverType=false) -> +  activateLink: (linkMatched, userMightOverType = false) ->      @removeHintMarkers()      if linkMatched.isLocalMarker @@ -391,25 +382,26 @@ class LinkHintsMode                clickEl.focus()              linkActivator clickEl -    installKeyboardBlocker = (startKeyboardBlocker) -> -      if linkMatched.isLocalMarker -        {top: viewportTop, left: viewportLeft} = DomUtils.getViewportTopLeft() -        for rect in (Rect.copy rect for rect in clickEl.getClientRects()) -          extend rect, top: rect.top + viewportTop, left: rect.left + viewportLeft -          flashEl = DomUtils.addFlashRect rect -          do (flashEl) -> HintCoordinator.onExit.push -> DomUtils.removeElement flashEl - -      if windowIsFocused() -        startKeyboardBlocker (isSuccess) -> HintCoordinator.sendMessage "exit", {isSuccess} +    # If flash elements are created, then this function can be used later to remove them. +    removeFlashElements = -> +    if linkMatched.isLocalMarker +      {top: viewportTop, left: viewportLeft} = DomUtils.getViewportTopLeft() +      flashElements = for rect in clickEl.getClientRects() +        DomUtils.addFlashRect Rect.translate rect, viewportLeft, viewportTop +      removeFlashElements = -> DomUtils.removeElement flashEl for flashEl in flashElements      # If we're using a keyboard blocker, then the frame with the focus sends the "exit" message, otherwise the      # frame containing the matched link does. -    if userMightOverType and Settings.get "waitForEnterForFilteredHints" -      installKeyboardBlocker (callback) -> new WaitForEnter callback -    else if userMightOverType -      installKeyboardBlocker (callback) -> new TypingProtector 200, callback +    if userMightOverType +      HintCoordinator.onExit.push removeFlashElements +      if windowIsFocused() +        callback = (isSuccess) -> HintCoordinator.sendMessage "exit", {isSuccess} +        if Settings.get "waitForEnterForFilteredHints" +          new WaitForEnter callback +        else +          new TypingProtector 200, callback      else if linkMatched.isLocalMarker -      DomUtils.flashRect linkMatched.rect +      Utils.setTimeout 400, removeFlashElements        HintCoordinator.sendMessage "exit", isSuccess: true    # @@ -659,9 +651,12 @@ LocalHints =      isClickable ||= @checkForAngularJs element      # Check for attributes that make an element clickable regardless of its tagName. -    if (element.hasAttribute("onclick") or -        element.getAttribute("role")?.toLowerCase() in ["button", "link"] or -        element.getAttribute("contentEditable")?.toLowerCase() in ["", "contentEditable", "true"]) +    if element.hasAttribute("onclick") or +        (role = element.getAttribute "role") and role.toLowerCase() in [ +          "button" , "tab" , "link", "checkbox", "menuitem", "menuitemcheckbox", "menuitemradio" +        ] or +        (contentEditable = element.getAttribute "contentEditable") and +          contentEditable.toLowerCase() in ["", "contenteditable", "true"]        isClickable = true      # Check for jsaction event listeners on the element. @@ -841,6 +836,8 @@ LocalHints =        if labelMap[element.id]          linkText = labelMap[element.id]          showLinkText = true +      else if element.getAttribute("type")?.toLowerCase() == "file" +        linkText = "Choose File"        else if element.type != "password"          linkText = element.value          if not linkText and 'placeholder' of element @@ -897,8 +894,9 @@ class WaitForEnter extends Mode            @exit()            callback false # false -> isSuccess. -root = exports ? window +root = exports ? (window.root ?= {})  root.LinkHints = LinkHints  root.HintCoordinator = HintCoordinator  # For tests:  extend root, {LinkHintsMode, LocalHints, AlphabetHints, WaitForEnter} +extend window, root unless exports? diff --git a/content_scripts/marks.coffee b/content_scripts/marks.coffee index 6eab3be6..ac653a52 100644 --- a/content_scripts/marks.coffee +++ b/content_scripts/marks.coffee @@ -52,7 +52,7 @@ Marks =              else                localStorage[@getLocationKey keyChar] = @getMarkString()                @showMessage "Created local mark", keyChar -          DomUtils.consumeKeyup event +          handlerStack.suppressEvent    activateGotoMode: ->      @mode = new Mode @@ -82,7 +82,8 @@ Marks =                  @showMessage "Jumped to local mark", keyChar                else                  @showMessage "Local mark not set", keyChar -          DomUtils.consumeKeyup event +          handlerStack.suppressEvent -root = exports ? window +root = exports ? (window.root ?= {})  root.Marks =  Marks +extend window, root unless exports? diff --git a/content_scripts/mode.coffee b/content_scripts/mode.coffee index 9de423ff..a4a91c1f 100644 --- a/content_scripts/mode.coffee +++ b/content_scripts/mode.coffee @@ -55,7 +55,7 @@ class Mode      # the need for modes which suppress all keyboard events 1) to provide handlers for all of those events,      # or 2) to worry about event suppression and event-handler return values.      if @options.suppressAllKeyboardEvents -      for type in [ "keydown", "keypress", "keyup" ] +      for type in [ "keydown", "keypress" ]          do (handler = @options[type]) =>            @options[type] = (event) => @alwaysSuppressPropagation => handler? event @@ -82,7 +82,7 @@ class Mode          "keydown": (event) =>            return @continueBubbling unless KeyboardUtils.isEscape event            @exit event, event.target -          DomUtils.consumeKeyup event +          @suppressEvent      # If @options.exitOnBlur is truthy, then it should be an element.  The mode will exit when that element      # loses the focus. @@ -120,16 +120,6 @@ class Mode        singletons[key]?.exit()        singletons[key] = this -    # If @options.passInitialKeyupEvents is set, then we pass initial non-printable keyup events to the page -    # or to other extensions (because the corresponding keydown events were passed).  This is used when -    # activating link hints, see #1522. -    if @options.passInitialKeyupEvents -      @push -        _name: "mode-#{@id}/passInitialKeyupEvents" -        keydown: => @alwaysContinueBubbling -> handlerStack.remove() -        keyup: (event) => -          if KeyboardUtils.isPrintable event then @suppressPropagation else @passEventToPage -      # if @options.suppressTrailingKeyEvents is set, then  -- on exit -- we suppress all key events until a      # subsquent (non-repeat) keydown or keypress.  In particular, the intention is to catch keyup events for      # keys which we have handled, but which otherwise might trigger page actions (if the page is listening for @@ -147,7 +137,6 @@ class Mode            name: "suppress-trailing-key-events"            keydown: handler            keypress: handler -          keyup: -> handlerStack.suppressPropagation      Mode.modes.push this      @setIndicator() @@ -209,5 +198,6 @@ class SuppressAllKeyboardEvents extends Mode        suppressAllKeyboardEvents: true      super extend defaults, options -root = exports ? window +root = exports ? (window.root ?= {})  extend root, {Mode, SuppressAllKeyboardEvents} +extend window, root unless exports? diff --git a/content_scripts/mode_find.coffee b/content_scripts/mode_find.coffee index 5a2da741..f19b5db4 100644 --- a/content_scripts/mode_find.coffee +++ b/content_scripts/mode_find.coffee @@ -6,7 +6,7 @@ class SuppressPrintable extends Mode    constructor: (options) ->      super options      handler = (event) => if KeyboardUtils.isPrintable event then @suppressEvent else @continueBubbling -    type = document.getSelection().type +    type = DomUtils.getSelectionType()      # We use unshift here, so we see events after normal mode, so we only see unmapped keys.      @unshift @@ -16,7 +16,7 @@ class SuppressPrintable extends Mode        keyup: (event) =>          # If the selection type has changed (usually, no longer "Range"), then the user is interacting with          # the input element, so we get out of the way.  See discussion of option 5c from #1415. -        if document.getSelection().type != type then @exit() else handler event +        @exit() if DomUtils.getSelectionType() != type  # When we use find, the selection/focus can land in a focusable/editable element.  In this situation, special  # considerations apply.  We implement three special cases: @@ -48,7 +48,7 @@ class PostFindMode extends SuppressPrintable        keydown: (event) =>          if KeyboardUtils.isEscape event            @exit() -          DomUtils.consumeKeyup event +          @suppressEvent          else            handlerStack.remove()            @continueBubbling @@ -79,9 +79,10 @@ class FindMode extends Mode    exit: (event) ->      super() -    handleEscapeForFindMode() if event +    FindMode.handleEscape() if event    restoreSelection: -> +    return unless @initialRange      range = @initialRange      selection = getSelection()      selection.removeAllRanges() @@ -201,20 +202,71 @@ class FindMode extends Mode    @restoreDefaultSelectionHighlight: forTrusted -> document.body.classList.remove("vimiumFindMode") +  # The user has found what they're looking for and is finished searching. We enter insert mode, if possible. +  @handleEscape: -> +    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. +    selection = window.getSelection() +    unless selection.isCollapsed +      range = window.getSelection().getRangeAt(0) +      window.getSelection().removeAllRanges() +      window.getSelection().addRange(range) +    focusFoundLink() || selectFoundInputElement() + +  # Save the query so the user can do further searches with it. +  @handleEnter: -> +    focusFoundLink() +    document.body.classList.add("vimiumFindMode") +    FindMode.saveQuery() + +  @findNext: (backwards) -> +    Marks.setPreviousPosition() +    FindMode.query.hasResults = FindMode.execute null, {backwards} + +    if FindMode.query.hasResults +      focusFoundLink() +      new PostFindMode() +    else +      HUD.showForDuration("No matches for '#{FindMode.query.rawQuery}'", 1000) +    checkReturnToViewPort: ->      window.scrollTo @scrollX, @scrollY if @options.returnToViewport  getCurrentRange = ->    selection = getSelection() -  if selection.type == "None" +  if DomUtils.getSelectionType(selection) == "None"      range = document.createRange()      range.setStart document.body, 0      range.setEnd document.body, 0      range    else -    selection.collapseToStart() if selection.type == "Range" +    selection.collapseToStart() if DomUtils.getSelectionType(selection) == "Range"      selection.getRangeAt 0 -root = exports ? window +getLinkFromSelection = -> +  node = window.getSelection().anchorNode +  while (node && node != document.body) +    return node if (node.nodeName.toLowerCase() == "a") +    node = node.parentNode +  null + +focusFoundLink = -> +  if (FindMode.query.hasResults) +    link = getLinkFromSelection() +    link.focus() if link + +selectFoundInputElement = -> +  # 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) + +root = exports ? (window.root ?= {})  root.PostFindMode = PostFindMode  root.FindMode = FindMode +extend window, root unless exports? diff --git a/content_scripts/mode_insert.coffee b/content_scripts/mode_insert.coffee index 1dc66d52..d2a33091 100644 --- a/content_scripts/mode_insert.coffee +++ b/content_scripts/mode_insert.coffee @@ -26,13 +26,12 @@ class InsertMode extends Mode          # An editable element in a shadow DOM is focused; blur it.          @insertModeLock.blur()        @exit event, event.target -      DomUtils.consumeKeyup event +      @suppressEvent      defaults =        name: "insert"        indicator: if not @permanent and not Settings.get "hideHud"  then "Insert mode"        keypress: handleKeyEvent -      keyup: handleKeyEvent        keydown: handleKeyEvent      super extend defaults, options @@ -129,6 +128,7 @@ class PassNextKeyMode extends Mode                @exit()          @passEventToPage -root = exports ? window +root = exports ? (window.root ?= {})  root.InsertMode = InsertMode  root.PassNextKeyMode = PassNextKeyMode +extend window, root unless exports? diff --git a/content_scripts/mode_key_handler.coffee b/content_scripts/mode_key_handler.coffee index 1b3b21e7..cca6b77a 100644 --- a/content_scripts/mode_key_handler.coffee +++ b/content_scripts/mode_key_handler.coffee @@ -27,8 +27,6 @@ class KeyHandlerMode extends Mode      super extend options,        keydown: @onKeydown.bind this -      # We cannot track keyup events if we lose the focus. -      blur: (event) => @alwaysContinueBubbling => @keydownEvents = {} if event.target == window      if options.exitOnEscape        # If we're part way through a command's key sequence, then a first Escape should reset the key state, @@ -38,7 +36,7 @@ class KeyHandlerMode extends Mode          keydown: (event) =>            if KeyboardUtils.isEscape(event) and not @isInResetState()              @reset() -            DomUtils.consumeKeyup event +            @suppressEvent            else              @continueBubbling @@ -49,11 +47,13 @@ class KeyHandlerMode extends Mode        DomUtils.consumeKeyup event, => @reset()      # If the help dialog loses the focus, then Escape should hide it; see point 2 in #2045.      else if isEscape and HelpDialog?.isShowing() -      DomUtils.consumeKeyup event, -> HelpDialog.toggle() +      HelpDialog.toggle() +      @suppressEvent      else if isEscape        @continueBubbling      else if @isMappedKey keyChar -      DomUtils.consumeKeyup event, => @handleKeyChar keyChar +      @handleKeyChar keyChar +      @suppressEvent      else if @isCountKey keyChar        digit = parseInt keyChar        @reset if @keyState.length == 1 then @countPrefix * 10 + digit else digit @@ -93,5 +93,6 @@ class KeyHandlerMode extends Mode        @exit() if @options.count? and --@options.count <= 0      @suppressEvent -root = exports ? window +root = exports ? (window.root ?= {})  root.KeyHandlerMode = KeyHandlerMode +extend window, root unless exports? diff --git a/content_scripts/mode_normal.coffee b/content_scripts/mode_normal.coffee new file mode 100644 index 00000000..1fe0618e --- /dev/null +++ b/content_scripts/mode_normal.coffee @@ -0,0 +1,369 @@ +class NormalMode extends KeyHandlerMode +  constructor: (options = {}) -> +    defaults = +      name: "normal" +      indicator: false # There is normally no mode indicator in normal mode. +      commandHandler: @commandHandler.bind this + +    super extend defaults, options + +    chrome.storage.local.get "normalModeKeyStateMapping", (items) => +      @setKeyMapping items.normalModeKeyStateMapping + +    chrome.storage.onChanged.addListener (changes, area) => +      if area == "local" and changes.normalModeKeyStateMapping?.newValue +        @setKeyMapping changes.normalModeKeyStateMapping.newValue + +  commandHandler: ({command: registryEntry, count}) -> +    count *= registryEntry.options.count ? 1 +    count = 1 if registryEntry.noRepeat + +    if registryEntry.repeatLimit? and registryEntry.repeatLimit < count +      return unless confirm """ +        You have asked Vimium to perform #{count} repetitions of the command: #{registryEntry.description}.\n +        Are you sure you want to continue?""" + +    if registryEntry.topFrame +      # We never return to a UI-component frame (e.g. the help dialog), it might have lost the focus. +      sourceFrameId = if window.isVimiumUIComponent then 0 else frameId +      chrome.runtime.sendMessage +        handler: "sendMessageToFrames", message: {name: "runInTopFrame", sourceFrameId, registryEntry} +    else if registryEntry.background +      chrome.runtime.sendMessage {handler: "runBackgroundCommand", registryEntry, count} +    else +      NormalModeCommands[registryEntry.command] count, {registryEntry} + +enterNormalMode = (count) -> +  new NormalMode +    indicator: "Normal mode (pass keys disabled)" +    exitOnEscape: true +    singleton: "enterNormalMode" +    count: count + +NormalModeCommands = +  # Scrolling. +  scrollToBottom: -> +    Marks.setPreviousPosition() +    Scroller.scrollTo "y", "max" +  scrollToTop: (count) -> +    Marks.setPreviousPosition() +    Scroller.scrollTo "y", (count - 1) * Settings.get("scrollStepSize") +  scrollToLeft: -> Scroller.scrollTo "x", 0 +  scrollToRight: -> Scroller.scrollTo "x", "max" +  scrollUp: (count) -> Scroller.scrollBy "y", -1 * Settings.get("scrollStepSize") * count +  scrollDown: (count) -> Scroller.scrollBy "y", Settings.get("scrollStepSize") * count +  scrollPageUp: (count) -> Scroller.scrollBy "y", "viewSize", -1/2 * count +  scrollPageDown: (count) -> Scroller.scrollBy "y", "viewSize", 1/2 * count +  scrollFullPageUp: (count) -> Scroller.scrollBy "y", "viewSize", -1 * count +  scrollFullPageDown: (count) -> Scroller.scrollBy "y", "viewSize", 1 * count +  scrollLeft: (count) -> Scroller.scrollBy "x", -1 * Settings.get("scrollStepSize") * count +  scrollRight: (count) -> Scroller.scrollBy "x", Settings.get("scrollStepSize") * count + +  # Page state. +  reload: (count, options) -> +    hard = options.registryEntry.options.hard ? false +    window.location.reload(hard) +  goBack: (count) -> history.go(-count) +  goForward: (count) -> history.go(count) + +  # Url manipulation. +  goUp: (count) -> +    url = window.location.href +    if (url[url.length - 1] == "/") +      url = url.substring(0, url.length - 1) + +    urlsplit = url.split("/") +    # make sure we haven't hit the base domain yet +    if (urlsplit.length > 3) +      urlsplit = urlsplit.slice(0, Math.max(3, urlsplit.length - count)) +      window.location.href = urlsplit.join('/') + +  goToRoot: -> +    window.location.href = window.location.origin + +  toggleViewSource: -> +    chrome.runtime.sendMessage { handler: "getCurrentTabUrl" }, (url) -> +      if (url.substr(0, 12) == "view-source:") +        url = url.substr(12, url.length - 12) +      else +        url = "view-source:" + url +      chrome.runtime.sendMessage {handler: "openUrlInNewTab", url} + +  copyCurrentUrl: -> +    chrome.runtime.sendMessage { handler: "getCurrentTabUrl" }, (url) -> +      HUD.copyToClipboard url +      url = url[0..25] + "...." if 28 < url.length +      HUD.showForDuration("Yanked #{url}", 2000) + +  openCopiedUrlInNewTab: (count) -> +    HUD.pasteFromClipboard (url) -> +      chrome.runtime.sendMessage { handler: "openUrlInNewTab", url, count } + +  openCopiedUrlInCurrentTab: -> +    HUD.pasteFromClipboard (url) -> +      chrome.runtime.sendMessage { handler: "openUrlInCurrentTab", url } + +  # Mode changes. +  enterInsertMode: -> +    # If a focusable element receives the focus, then we exit and leave the permanently-installed insert-mode +    # instance to take over. +    new InsertMode global: true, exitOnFocus: true + +  enterVisualMode: -> +    new VisualMode userLaunchedMode: true + +  enterVisualLineMode: -> +    new VisualLineMode userLaunchedMode: true + +  enterFindMode: -> +    Marks.setPreviousPosition() +    new FindMode() + +  # Find. +  performFind: (count) -> FindMode.findNext false for [0...count] by 1 +  performBackwardsFind: (count) -> FindMode.findNext true for [0...count] by 1 + +  # Misc. +  mainFrame: -> focusThisFrame highlight: true, forceFocusThisFrame: true +  showHelp: (sourceFrameId) -> HelpDialog.toggle {sourceFrameId, showAllCommandDetails: false} + +  passNextKey: (count, options) -> +    if options.registryEntry.options.normal +      enterNormalMode count +    else +      new PassNextKeyMode count + +  goPrevious: -> +    previousPatterns = Settings.get("previousPatterns") || "" +    previousStrings = previousPatterns.split(",").filter( (s) -> s.trim().length ) +    findAndFollowRel("prev") || findAndFollowLink(previousStrings) + +  goNext: -> +    nextPatterns = Settings.get("nextPatterns") || "" +    nextStrings = nextPatterns.split(",").filter( (s) -> s.trim().length ) +    findAndFollowRel("next") || findAndFollowLink(nextStrings) + +  focusInput: (count) -> +    # Focus the first input element on the page, and create overlays to highlight all the input elements, with +    # the currently-focused element highlighted specially. Tabbing will shift focus to the next input element. +    # Pressing any other key will remove the overlays and the special tab behavior. +    resultSet = DomUtils.evaluateXPath textInputXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE +    visibleInputs = +      for i in [0...resultSet.snapshotLength] by 1 +        element = resultSet.snapshotItem i +        continue unless DomUtils.getVisibleClientRect element, true +        { element, index: i, rect: Rect.copy element.getBoundingClientRect() } + +    visibleInputs.sort ({element: element1, index: i1}, {element: element2, index: i2}) -> +      # Put elements with a lower positive tabIndex first, keeping elements in DOM order. +      if element1.tabIndex > 0 +        if element2.tabIndex > 0 +          tabDifference = element1.tabIndex - element2.tabIndex +          if tabDifference != 0 +            tabDifference +          else +            i1 - i2 +        else +          -1 +      else if element2.tabIndex > 0 +        1 +      else +        i1 - i2 + +    if visibleInputs.length == 0 +      HUD.showForDuration("There are no inputs to focus.", 1000) +      return + +    # This is a hack to improve usability on the Vimium options page.  We prime the recently-focused input +    # to be the key-mappings input.  Arguably, this is the input that the user is most likely to use. +    recentlyFocusedElement = lastFocusedInput() + +    selectedInputIndex = +      if count == 1 +        # As the starting index, we pick that of the most recently focused input element (or 0). +        elements = visibleInputs.map (visibleInput) -> visibleInput.element +        Math.max 0, elements.indexOf recentlyFocusedElement +      else +        Math.min(count, visibleInputs.length) - 1 + +    hints = for tuple in visibleInputs +      hint = DomUtils.createElement "div" +      hint.className = "vimiumReset internalVimiumInputHint vimiumInputHint" + +      # minus 1 for the border +      hint.style.left = (tuple.rect.left - 1) + window.scrollX + "px" +      hint.style.top = (tuple.rect.top - 1) + window.scrollY  + "px" +      hint.style.width = tuple.rect.width + "px" +      hint.style.height = tuple.rect.height + "px" + +      hint + +    new FocusSelector hints, visibleInputs, selectedInputIndex + +if LinkHints? +  extend NormalModeCommands, +    "LinkHints.activateMode": LinkHints.activateMode.bind LinkHints +    "LinkHints.activateModeToOpenInNewTab": LinkHints.activateModeToOpenInNewTab.bind LinkHints +    "LinkHints.activateModeToOpenInNewForegroundTab": LinkHints.activateModeToOpenInNewForegroundTab.bind LinkHints +    "LinkHints.activateModeWithQueue": LinkHints.activateModeWithQueue.bind LinkHints +    "LinkHints.activateModeToOpenIncognito": LinkHints.activateModeToOpenIncognito.bind LinkHints +    "LinkHints.activateModeToDownloadLink": LinkHints.activateModeToDownloadLink.bind LinkHints +    "LinkHints.activateModeToCopyLinkUrl": LinkHints.activateModeToCopyLinkUrl.bind LinkHints + +if Vomnibar? +  extend NormalModeCommands, +    "Vomnibar.activate": Vomnibar.activate.bind Vomnibar +    "Vomnibar.activateInNewTab": Vomnibar.activateInNewTab.bind Vomnibar +    "Vomnibar.activateTabSelection": Vomnibar.activateTabSelection.bind Vomnibar +    "Vomnibar.activateBookmarks": Vomnibar.activateBookmarks.bind Vomnibar +    "Vomnibar.activateBookmarksInNewTab": Vomnibar.activateBookmarksInNewTab.bind Vomnibar +    "Vomnibar.activateEditUrl": Vomnibar.activateEditUrl.bind Vomnibar +    "Vomnibar.activateEditUrlInNewTab": Vomnibar.activateEditUrlInNewTab.bind Vomnibar + +if Marks? +  extend NormalModeCommands, +    "Marks.activateCreateMode": Marks.activateCreateMode.bind Marks +    "Marks.activateGotoMode": Marks.activateGotoMode.bind Marks + +# The types in <input type="..."> that we consider for focusInput command. Right now this is recalculated in +# each content script. Alternatively we could calculate it once in the background page and use a request to +# fetch it each time. +# Should we include the HTML5 date pickers here? + +# The corresponding XPath for such elements. +textInputXPath = (-> +  textInputTypes = [ "text", "search", "email", "url", "number", "password", "date", "tel" ] +  inputElements = ["input[" + +    "(" + textInputTypes.map((type) -> '@type="' + type + '"').join(" or ") + "or not(@type))" + +    " and not(@disabled or @readonly)]", +    "textarea", "*[@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"] +  DomUtils?.makeXPath(inputElements) +)() + +# used by the findAndFollow* functions. +followLink = (linkElement) -> +  if (linkElement.nodeName.toLowerCase() == "link") +    window.location.href = linkElement.href +  else +    # if we can click on it, don't simply set location.href: some next/prev links are meant to trigger AJAX +    # calls, like the 'more' button on GitHub's newsfeed. +    linkElement.scrollIntoView() +    DomUtils.simulateClick(linkElement) + +# +# Find and follow a link which matches any one of a list of strings. If there are multiple such links, they +# are prioritized for shortness, by their position in :linkStrings, how far down the page they are located, +# and finally by whether the match is exact. Practically speaking, this means we favor 'next page' over 'the +# next big thing', and 'more' over 'nextcompany', even if 'next' occurs before 'more' in :linkStrings. +# +findAndFollowLink = (linkStrings) -> +  linksXPath = DomUtils.makeXPath(["a", "*[@onclick or @role='link' or contains(@class, 'button')]"]) +  links = DomUtils.evaluateXPath(linksXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) +  candidateLinks = [] + +  # at the end of this loop, candidateLinks will contain all visible links that match our patterns +  # links lower in the page are more likely to be the ones we want, so we loop through the snapshot backwards +  for i in [(links.snapshotLength - 1)..0] by -1 +    link = links.snapshotItem(i) + +    # ensure link is visible (we don't mind if it is scrolled offscreen) +    boundingClientRect = link.getBoundingClientRect() +    if (boundingClientRect.width == 0 || boundingClientRect.height == 0) +      continue +    computedStyle = window.getComputedStyle(link, null) +    if (computedStyle.getPropertyValue("visibility") != "visible" || +        computedStyle.getPropertyValue("display") == "none") +      continue + +    linkMatches = false +    for linkString in linkStrings +      if link.innerText.toLowerCase().indexOf(linkString) != -1 || +          0 <= link.value?.indexOf? linkString +        linkMatches = true +        break +    continue unless linkMatches + +    candidateLinks.push(link) + +  return if (candidateLinks.length == 0) + +  for link in candidateLinks +    link.wordCount = link.innerText.trim().split(/\s+/).length + +  # We can use this trick to ensure that Array.sort is stable. We need this property to retain the reverse +  # in-page order of the links. + +  candidateLinks.forEach((a,i) -> a.originalIndex = i) + +  # favor shorter links, and ignore those that are more than one word longer than the shortest link +  candidateLinks = +    candidateLinks +      .sort((a, b) -> +        if (a.wordCount == b.wordCount) then a.originalIndex - b.originalIndex else a.wordCount - b.wordCount +      ) +      .filter((a) -> a.wordCount <= candidateLinks[0].wordCount + 1) + +  for linkString in linkStrings +    exactWordRegex = +      if /\b/.test(linkString[0]) or /\b/.test(linkString[linkString.length - 1]) +        new RegExp "\\b" + linkString + "\\b", "i" +      else +        new RegExp linkString, "i" +    for candidateLink in candidateLinks +      if exactWordRegex.test(candidateLink.innerText) || +          (candidateLink.value && exactWordRegex.test(candidateLink.value)) +        followLink(candidateLink) +        return true +  false + +findAndFollowRel = (value) -> +  relTags = ["link", "a", "area"] +  for tag in relTags +    elements = document.getElementsByTagName(tag) +    for element in elements +      if (element.hasAttribute("rel") && element.rel.toLowerCase() == value) +        followLink(element) +        return true + +class FocusSelector extends Mode +  constructor: (hints, visibleInputs, selectedInputIndex) -> +    super +      name: "focus-selector" +      exitOnClick: true +      keydown: (event) => +        if event.key == "Tab" +          hints[selectedInputIndex].classList.remove 'internalVimiumSelectedInputHint' +          selectedInputIndex += hints.length + (if event.shiftKey then -1 else 1) +          selectedInputIndex %= hints.length +          hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' +          DomUtils.simulateSelect visibleInputs[selectedInputIndex].element +          @suppressEvent +        else unless event.key == "Shift" +          @exit() +          # Give the new mode the opportunity to handle the event. +          @restartBubbling + +    @hintContainingDiv = DomUtils.addElementList hints, +      id: "vimiumInputMarkerContainer" +      className: "vimiumReset" + +    DomUtils.simulateSelect visibleInputs[selectedInputIndex].element +    if visibleInputs.length == 1 +      @exit() +      return +    else +      hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' + +  exit: -> +    super() +    DomUtils.removeElement @hintContainingDiv +    if document.activeElement and DomUtils.isEditable document.activeElement +      new InsertMode +        singleton: "post-find-mode/focus-input" +        targetElement: document.activeElement +        indicator: false + +root = exports ? (window.root ?= {}) +root.NormalMode = NormalMode +root.NormalModeCommands = NormalModeCommands +extend window, root unless exports? diff --git a/content_scripts/mode_visual.coffee b/content_scripts/mode_visual.coffee index 28097005..4c6578cd 100644 --- a/content_scripts/mode_visual.coffee +++ b/content_scripts/mode_visual.coffee @@ -312,7 +312,7 @@ class VisualMode extends KeyHandlerMode    yank: (args = {}) ->      @yankedText = @selection.toString()      @exit() -    chrome.runtime.sendMessage handler: "copyToClipboard", data: @yankedText +    HUD.copyToClipboard @yankedText      message = @yankedText.replace /\s+/g, " "      message = message[...12] + "..." if 15 < @yankedText.length @@ -380,6 +380,7 @@ class CaretMode extends VisualMode            return true      false -root = exports ? window +root = exports ? (window.root ?= {})  root.VisualMode = VisualMode  root.VisualLineMode = VisualLineMode +extend window, root unless exports? diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 56862d49..f65062e4 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -95,7 +95,14 @@ findScrollableElement = (element, direction, amount, factor) ->  # On some pages, the scrolling element is not actually scrollable.  Here, we search the document for the  # largest visible element which does scroll vertically. This is used to initialize activatedElement. See  # #1358. -firstScrollableElement = (element=getScrollingElement()) -> +firstScrollableElement = (element = null) -> +  unless element +    scrollingElement = getScrollingElement() +    if doesScroll(scrollingElement, "y", 1, 1) or doesScroll(scrollingElement, "y", -1, 1) +      return scrollingElement +    else +      element = document.body ? getScrollingElement() +    if doesScroll(element, "y", 1, 1) or doesScroll(element, "y", -1, 1)      element    else @@ -301,5 +308,6 @@ Scroller =          element = findScrollableElement element, "x", amount, 1          CoreScroller.scroll element, "x", amount, false -root = exports ? window +root = exports ? (window.root ?= {})  root.Scroller = Scroller +extend window, root unless exports? diff --git a/content_scripts/ui_component.coffee b/content_scripts/ui_component.coffee index 203f0c8c..c71bfb35 100644 --- a/content_scripts/ui_component.coffee +++ b/content_scripts/ui_component.coffee @@ -96,5 +96,6 @@ class UIComponent          @options = null          @postMessage "hidden" # Inform the UI component that it is hidden. -root = exports ? window +root = exports ? (window.root ?= {})  root.UIComponent = UIComponent +extend window, root unless exports? diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 4f2e805d..432fa7a2 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -2,6 +2,12 @@  # This content script must be run prior to domReady so that we perform some operations very early.  # +root = exports ? (window.root ?= {}) +# On Firefox, sometimes the variables assigned to window are lost (bug 1408996), so we reinstall them. +# NOTE(mrmr1993): This bug leads to catastrophic failure (ie. nothing works and errors abound). +DomUtils.documentReady -> +  root.extend window, root unless extend? +  isEnabledForUrl = true  isIncognitoMode = chrome.extension.inIncognitoContext  normalMode = null @@ -16,21 +22,6 @@ windowIsFocused = do ->      windowHasFocus = false if event.target == window; true    -> windowHasFocus -# The types in <input type="..."> that we consider for focusInput command. Right now this is recalculated in -# each content script. Alternatively we could calculate it once in the background page and use a request to -# fetch it each time. -# Should we include the HTML5 date pickers here? - -# The corresponding XPath for such elements. -textInputXPath = (-> -  textInputTypes = [ "text", "search", "email", "url", "number", "password", "date", "tel" ] -  inputElements = ["input[" + -    "(" + textInputTypes.map((type) -> '@type="' + type + '"').join(" or ") + "or not(@type))" + -    " and not(@disabled or @readonly)]", -    "textarea", "*[@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"] -  DomUtils.makeXPath(inputElements) -)() -  # This is set by Frame.registerFrameId(). A frameId of 0 indicates that this is the top frame in the tab.  frameId = null @@ -111,41 +102,6 @@ handlerStack.push          target = target.parentElement      true -class NormalMode extends KeyHandlerMode -  constructor: (options = {}) -> -    defaults = -      name: "normal" -      indicator: false # There is normally no mode indicator in normal mode. -      commandHandler: @commandHandler.bind this - -    super extend defaults, options - -    chrome.storage.local.get "normalModeKeyStateMapping", (items) => -      @setKeyMapping items.normalModeKeyStateMapping - -    chrome.storage.onChanged.addListener (changes, area) => -      if area == "local" and changes.normalModeKeyStateMapping?.newValue -        @setKeyMapping changes.normalModeKeyStateMapping.newValue - -  commandHandler: ({command: registryEntry, count}) -> -    count *= registryEntry.options.count ? 1 -    count = 1 if registryEntry.noRepeat - -    if registryEntry.repeatLimit? and registryEntry.repeatLimit < count -      return unless confirm """ -        You have asked Vimium to perform #{count} repetitions of the command: #{registryEntry.description}.\n -        Are you sure you want to continue?""" - -    if registryEntry.topFrame -      # We never return to a UI-component frame (e.g. the help dialog), it might have lost the focus. -      sourceFrameId = if window.isVimiumUIComponent then 0 else frameId -      chrome.runtime.sendMessage -        handler: "sendMessageToFrames", message: {name: "runInTopFrame", sourceFrameId, registryEntry} -    else if registryEntry.background -      chrome.runtime.sendMessage {handler: "runBackgroundCommand", registryEntry, count} -    else -      Utils.invokeCommandString registryEntry.command, count, {registryEntry} -  installModes = ->    # Install the permanent modes. The permanently-installed insert mode tracks focus/blur events, and    # activates/deactivates itself accordingly. @@ -186,7 +142,7 @@ initializePreDomReady = ->      frameFocused: -> # A frame has received the focus; we don't care here (UI components handle this).      checkEnabledAfterURLChange: checkEnabledAfterURLChange      runInTopFrame: ({sourceFrameId, registryEntry}) -> -      Utils.invokeCommandString registryEntry.command, sourceFrameId, registryEntry if DomUtils.isTopFrame() +      NormalModeCommands[registryEntry.command] sourceFrameId, registryEntry if DomUtils.isTopFrame()      linkHintsMessage: (request) -> HintCoordinator[request.messageType] request    chrome.runtime.onMessage.addListener (request, sender, sendResponse) -> @@ -201,6 +157,7 @@ initializePreDomReady = ->  # Wrapper to install event listeners.  Syntactic sugar.  installListener = (element, event, callback) ->    element.addEventListener(event, forTrusted(-> +    root.extend window, root unless extend? # See #2800.      if isEnabledForUrl then callback.apply(this, arguments) else true    ), true) @@ -245,7 +202,7 @@ Frame =    postMessage: (handler, request = {}) -> @port.postMessage extend request, {handler}    linkHintsMessage: (request) -> HintCoordinator[request.messageType] request    registerFrameId: ({chromeFrameId}) -> -    frameId = window.frameId = chromeFrameId +    frameId = root.frameId = window.frameId = chromeFrameId      # We register a frame immediately only if it is focused or its window isn't tiny.  We register tiny      # frames later, when necessary.  This affects focusFrame() and link hints.      if windowIsFocused() or not DomUtils.windowIsTooSmall() @@ -264,6 +221,7 @@ Frame =      @port = chrome.runtime.connect name: "frames"      @port.onMessage.addListener (request) => +      root.extend window, root unless extend? # See #2800 and #2831.        (@listeners[request.handler] ? this[request.handler]) request      # We disable the content scripts when we lose contact with the background page, or on unload. @@ -280,7 +238,7 @@ Frame =      handlerStack.reset()      isEnabledForUrl = false      window.removeEventListener "focus", onFocus -    window.removeEventListener "hashchange", onFocus +    window.removeEventListener "hashchange", checkEnabledAfterURLChange  setScrollPosition = ({ scrollX, scrollY }) ->    DomUtils.documentReady -> @@ -327,171 +285,17 @@ focusThisFrame = (request) ->    document.activeElement.blur() if document.activeElement.tagName.toLowerCase() == "iframe"    flashFrame() if request.highlight -extend window, -  scrollToBottom: -> -    Marks.setPreviousPosition() -    Scroller.scrollTo "y", "max" -  scrollToTop: (count) -> -    Marks.setPreviousPosition() -    Scroller.scrollTo "y", (count - 1) * Settings.get("scrollStepSize") -  scrollToLeft: -> Scroller.scrollTo "x", 0 -  scrollToRight: -> Scroller.scrollTo "x", "max" -  scrollUp: (count) -> Scroller.scrollBy "y", -1 * Settings.get("scrollStepSize") * count -  scrollDown: (count) -> Scroller.scrollBy "y", Settings.get("scrollStepSize") * count -  scrollPageUp: (count) -> Scroller.scrollBy "y", "viewSize", -1/2 * count -  scrollPageDown: (count) -> Scroller.scrollBy "y", "viewSize", 1/2 * count -  scrollFullPageUp: (count) -> Scroller.scrollBy "y", "viewSize", -1 * count -  scrollFullPageDown: (count) -> Scroller.scrollBy "y", "viewSize", 1 * count -  scrollLeft: (count) -> Scroller.scrollBy "x", -1 * Settings.get("scrollStepSize") * count -  scrollRight: (count) -> Scroller.scrollBy "x", Settings.get("scrollStepSize") * count - -extend window, -  reload: -> window.location.reload() -  goBack: (count) -> history.go(-count) -  goForward: (count) -> history.go(count) - -  goUp: (count) -> -    url = window.location.href -    if (url[url.length - 1] == "/") -      url = url.substring(0, url.length - 1) - -    urlsplit = url.split("/") -    # make sure we haven't hit the base domain yet -    if (urlsplit.length > 3) -      urlsplit = urlsplit.slice(0, Math.max(3, urlsplit.length - count)) -      window.location.href = urlsplit.join('/') - -  goToRoot: -> -    window.location.href = window.location.origin - -  mainFrame: -> focusThisFrame highlight: true, forceFocusThisFrame: true - -  toggleViewSource: -> -    chrome.runtime.sendMessage { handler: "getCurrentTabUrl" }, (url) -> -      if (url.substr(0, 12) == "view-source:") -        url = url.substr(12, url.length - 12) -      else -        url = "view-source:" + url -      chrome.runtime.sendMessage {handler: "openUrlInNewTab", url} - -  copyCurrentUrl: -> -    # TODO(ilya): When the following bug is fixed, revisit this approach of sending back to the background -    # page to copy. -    # http://code.google.com/p/chromium/issues/detail?id=55188 -    chrome.runtime.sendMessage { handler: "getCurrentTabUrl" }, (url) -> -      chrome.runtime.sendMessage { handler: "copyToClipboard", data: url } -      url = url[0..25] + "...." if 28 < url.length -      HUD.showForDuration("Yanked #{url}", 2000) - -  enterInsertMode: -> -    # If a focusable element receives the focus, then we exit and leave the permanently-installed insert-mode -    # instance to take over. -    new InsertMode global: true, exitOnFocus: true - -  enterVisualMode: -> -    new VisualMode userLaunchedMode: true - -  enterVisualLineMode: -> -    new VisualLineMode userLaunchedMode: true - -  passNextKey: (count, options) -> -    if options.registryEntry.options.normal -      enterNormalMode count -    else -      new PassNextKeyMode count - -  enterNormalMode: (count) -> -    new NormalMode -      indicator: "Normal mode (pass keys disabled)" -      exitOnEscape: true -      singleton: "enterNormalMode" -      count: count - -  focusInput: do -> -    # Track the most recently focused input element. -    recentlyFocusedElement = null -    window.addEventListener "focus", -      forTrusted (event) -> recentlyFocusedElement = event.target if DomUtils.isEditable event.target -    , true - -    (count) -> -      mode = InsertMode -      # Focus the first input element on the page, and create overlays to highlight all the input elements, with -      # the currently-focused element highlighted specially. Tabbing will shift focus to the next input element. -      # Pressing any other key will remove the overlays and the special tab behavior. -      # The mode argument is the mode to enter once an input is selected. -      resultSet = DomUtils.evaluateXPath textInputXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE -      visibleInputs = -        for i in [0...resultSet.snapshotLength] by 1 -          element = resultSet.snapshotItem i -          continue unless DomUtils.getVisibleClientRect element, true -          { element, rect: Rect.copy element.getBoundingClientRect() } - -      if visibleInputs.length == 0 -        HUD.showForDuration("There are no inputs to focus.", 1000) -        return - -      # This is a hack to improve usability on the Vimium options page.  We prime the recently-focused input -      # to be the key-mappings input.  Arguably, this is the input that the user is most likely to use. -      recentlyFocusedElement ?= document.getElementById "keyMappings" if window.isVimiumOptionsPage - -      selectedInputIndex = -        if count == 1 -          # As the starting index, we pick that of the most recently focused input element (or 0). -          elements = visibleInputs.map (visibleInput) -> visibleInput.element -          Math.max 0, elements.indexOf recentlyFocusedElement -        else -          Math.min(count, visibleInputs.length) - 1 - -      hints = for tuple in visibleInputs -        hint = DomUtils.createElement "div" -        hint.className = "vimiumReset internalVimiumInputHint vimiumInputHint" - -        # minus 1 for the border -        hint.style.left = (tuple.rect.left - 1) + window.scrollX + "px" -        hint.style.top = (tuple.rect.top - 1) + window.scrollY  + "px" -        hint.style.width = tuple.rect.width + "px" -        hint.style.height = tuple.rect.height + "px" - -        hint - -      new class FocusSelector extends Mode -        constructor: -> -          super -            name: "focus-selector" -            exitOnClick: true -            keydown: (event) => -              if event.key == "Tab" -                hints[selectedInputIndex].classList.remove 'internalVimiumSelectedInputHint' -                selectedInputIndex += hints.length + (if event.shiftKey then -1 else 1) -                selectedInputIndex %= hints.length -                hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' -                DomUtils.simulateSelect visibleInputs[selectedInputIndex].element -                @suppressEvent -              else unless event.key == "Shift" -                @exit() -                # Give the new mode the opportunity to handle the event. -                @restartBubbling - -          @hintContainingDiv = DomUtils.addElementList hints, -            id: "vimiumInputMarkerContainer" -            className: "vimiumReset" - -          DomUtils.simulateSelect visibleInputs[selectedInputIndex].element -          if visibleInputs.length == 1 -            @exit() -            return -          else -            hints[selectedInputIndex].classList.add 'internalVimiumSelectedInputHint' - -        exit: -> -          super() -          DomUtils.removeElement @hintContainingDiv -          if mode and document.activeElement and DomUtils.isEditable document.activeElement -            new mode -              singleton: "post-find-mode/focus-input" -              targetElement: document.activeElement -              indicator: false +# Used by focusInput command. +root.lastFocusedInput = do -> +  # Track the most recently focused input element. +  recentlyFocusedElement = null +  window.addEventListener "focus", +    forTrusted (event) -> +      DomUtils = window.DomUtils ? root.DomUtils # Workaround FF bug 1408996. +      if DomUtils.isEditable event.target +        recentlyFocusedElement = event.target +  , true +  -> recentlyFocusedElement  # Checks if Vimium should be enabled or not in this frame.  As a side effect, it also informs the background  # page whether this frame has the focus, allowing the background page to track the active frame's URL and set @@ -513,165 +317,8 @@ checkIfEnabledForUrl = do ->  checkEnabledAfterURLChange = forTrusted ->    checkIfEnabledForUrl() if windowIsFocused() -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. -  selection = window.getSelection() -  unless selection.isCollapsed -    range = window.getSelection().getRangeAt(0) -    window.getSelection().removeAllRanges() -    window.getSelection().addRange(range) -  focusFoundLink() || selectFoundInputElement() - -# <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 = -> -  focusFoundLink() -  document.body.classList.add("vimiumFindMode") -  FindMode.saveQuery() - -focusFoundLink = -> -  if (FindMode.query.hasResults) -    link = getLinkFromSelection() -    link.focus() if link - -selectFoundInputElement = -> -  # 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) - -findAndFocus = (backwards) -> -  Marks.setPreviousPosition() -  FindMode.query.hasResults = FindMode.execute null, {backwards} - -  if FindMode.query.hasResults -    focusFoundLink() -    new PostFindMode() -  else -    HUD.showForDuration("No matches for '#{FindMode.query.rawQuery}'", 1000) - -performFind = (count) -> findAndFocus false for [0...count] by 1 -performBackwardsFind = (count) -> findAndFocus true for [0...count] by 1 - -getLinkFromSelection = -> -  node = window.getSelection().anchorNode -  while (node && node != document.body) -    return node if (node.nodeName.toLowerCase() == "a") -    node = node.parentNode -  null - -# used by the findAndFollow* functions. -followLink = (linkElement) -> -  if (linkElement.nodeName.toLowerCase() == "link") -    window.location.href = linkElement.href -  else -    # if we can click on it, don't simply set location.href: some next/prev links are meant to trigger AJAX -    # calls, like the 'more' button on GitHub's newsfeed. -    linkElement.scrollIntoView() -    DomUtils.simulateClick(linkElement) - -# -# Find and follow a link which matches any one of a list of strings. If there are multiple such links, they -# are prioritized for shortness, by their position in :linkStrings, how far down the page they are located, -# and finally by whether the match is exact. Practically speaking, this means we favor 'next page' over 'the -# next big thing', and 'more' over 'nextcompany', even if 'next' occurs before 'more' in :linkStrings. -# -findAndFollowLink = (linkStrings) -> -  linksXPath = DomUtils.makeXPath(["a", "*[@onclick or @role='link' or contains(@class, 'button')]"]) -  links = DomUtils.evaluateXPath(linksXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE) -  candidateLinks = [] - -  # at the end of this loop, candidateLinks will contain all visible links that match our patterns -  # links lower in the page are more likely to be the ones we want, so we loop through the snapshot backwards -  for i in [(links.snapshotLength - 1)..0] by -1 -    link = links.snapshotItem(i) - -    # ensure link is visible (we don't mind if it is scrolled offscreen) -    boundingClientRect = link.getBoundingClientRect() -    if (boundingClientRect.width == 0 || boundingClientRect.height == 0) -      continue -    computedStyle = window.getComputedStyle(link, null) -    if (computedStyle.getPropertyValue("visibility") != "visible" || -        computedStyle.getPropertyValue("display") == "none") -      continue - -    linkMatches = false -    for linkString in linkStrings -      if link.innerText.toLowerCase().indexOf(linkString) != -1 || -          0 <= link.value?.indexOf? linkString -        linkMatches = true -        break -    continue unless linkMatches - -    candidateLinks.push(link) - -  return if (candidateLinks.length == 0) - -  for link in candidateLinks -    link.wordCount = link.innerText.trim().split(/\s+/).length - -  # We can use this trick to ensure that Array.sort is stable. We need this property to retain the reverse -  # in-page order of the links. - -  candidateLinks.forEach((a,i) -> a.originalIndex = i) - -  # favor shorter links, and ignore those that are more than one word longer than the shortest link -  candidateLinks = -    candidateLinks -      .sort((a, b) -> -        if (a.wordCount == b.wordCount) then a.originalIndex - b.originalIndex else a.wordCount - b.wordCount -      ) -      .filter((a) -> a.wordCount <= candidateLinks[0].wordCount + 1) - -  for linkString in linkStrings -    exactWordRegex = -      if /\b/.test(linkString[0]) or /\b/.test(linkString[linkString.length - 1]) -        new RegExp "\\b" + linkString + "\\b", "i" -      else -        new RegExp linkString, "i" -    for candidateLink in candidateLinks -      if exactWordRegex.test(candidateLink.innerText) || -          (candidateLink.value && exactWordRegex.test(candidateLink.value)) -        followLink(candidateLink) -        return true -  false - -findAndFollowRel = (value) -> -  relTags = ["link", "a", "area"] -  for tag in relTags -    elements = document.getElementsByTagName(tag) -    for element in elements -      if (element.hasAttribute("rel") && element.rel.toLowerCase() == value) -        followLink(element) -        return true - -window.goPrevious = -> -  previousPatterns = Settings.get("previousPatterns") || "" -  previousStrings = previousPatterns.split(",").filter( (s) -> s.trim().length ) -  findAndFollowRel("prev") || findAndFollowLink(previousStrings) - -window.goNext = -> -  nextPatterns = Settings.get("nextPatterns") || "" -  nextStrings = nextPatterns.split(",").filter( (s) -> s.trim().length ) -  findAndFollowRel("next") || findAndFollowLink(nextStrings) - -# Enters find mode.  Returns the new find-mode instance. -enterFindMode = -> -  Marks.setPreviousPosition() -  new FindMode() - -window.showHelp = (sourceFrameId) -> -  HelpDialog.toggle {sourceFrameId, showAllCommandDetails: false} -  # If we are in the help dialog iframe, then HelpDialog is already defined with the necessary functions. -window.HelpDialog ?= +root.HelpDialog ?=    helpUI: null    isShowing: -> @helpUI?.showing    abort: -> @helpUI.hide false if @isShowing() @@ -688,14 +335,13 @@ window.HelpDialog ?=  initializePreDomReady()  DomUtils.documentReady initializeOnDomReady -root = exports ? window  root.handlerStack = handlerStack  root.frameId = frameId  root.Frame = Frame  root.windowIsFocused = windowIsFocused  root.bgLog = bgLog -# These are exported for find mode and link-hints mode. -extend root, {handleEscapeForFindMode, handleEnterForFindMode, performFind, performBackwardsFind, -  enterFindMode, focusThisFrame} +# These are exported for normal mode and link-hints mode. +extend root, {focusThisFrame}  # These are exported only for the tests.  extend root, {installModes} +extend window, root unless exports? diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee index 14d72e87..ad98aa48 100644 --- a/content_scripts/vomnibar.coffee +++ b/content_scripts/vomnibar.coffee @@ -58,5 +58,6 @@ Vomnibar =      HelpDialog.abort()      @vomnibarUI.activate extend options, { name: "activate", sourceFrameId, focus: true } -root = exports ? window +root = exports ? (window.root ?= {})  root.Vomnibar = Vomnibar +extend window, root unless exports? diff --git a/lib/clipboard.coffee b/lib/clipboard.coffee index af143dd9..a9e2e82e 100644 --- a/lib/clipboard.coffee +++ b/lib/clipboard.coffee @@ -1,8 +1,9 @@  Clipboard = -  _createTextArea: -> -    textArea = document.createElement "textarea" +  _createTextArea: (tagName = "textarea") -> +    textArea = document.createElement tagName      textArea.style.position = "absolute"      textArea.style.left = "-100%" +    textArea.contentEditable = "true"      textArea    # http://groups.google.com/group/chromium-extensions/browse_thread/thread/49027e7f3b04f68/f6ab2457dee5bf55 @@ -16,14 +17,15 @@ Clipboard =      document.body.removeChild(textArea)    paste: -> -    textArea = @_createTextArea() +    textArea = @_createTextArea "div" # Use a <div> so Firefox pastes rich text.      document.body.appendChild(textArea)      textArea.focus()      document.execCommand("Paste") -    value = textArea.value +    value = textArea.innerText      document.body.removeChild(textArea)      value -root = exports ? window +root = exports ? (window.root ?= {})  root.Clipboard = Clipboard +extend window, root unless exports? diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee index ff5991dc..67d5a44c 100644 --- a/lib/dom_utils.coffee +++ b/lib/dom_utils.coffee @@ -219,7 +219,7 @@ DomUtils =        node = selection.anchorNode        node and @isDOMDescendant element, node      else -      if selection.type == "Range" and selection.isCollapsed +      if DomUtils.getSelectionType(selection) == "Range" and selection.isCollapsed  	      # The selection is inside the Shadow DOM of a node. We can check the node it registers as being  	      # before, since this represents the node whose Shadow DOM it's inside.          containerNode = selection.anchorNode.childNodes[selection.anchorOffset] @@ -344,7 +344,7 @@ DomUtils =    consumeKeyup: do ->      handlerId = null -    (event, callback = null) -> +    (event, callback = null, suppressPropagation) ->        unless event.repeat          handlerStack.remove handlerId if handlerId?          code = event.code @@ -353,17 +353,25 @@ DomUtils =            keyup: (event) ->              return handlerStack.continueBubbling unless event.code == code              @remove() -            handlerStack.suppressEvent +            if suppressPropagation +              DomUtils.suppressPropagation event +            else +              DomUtils.suppressEvent event +            handlerStack.continueBubbling            # We cannot track keyup events if we lose the focus.            blur: (event) ->              @remove() if event.target == window              handlerStack.continueBubbling        callback?() -      @suppressEvent event -      handlerStack.suppressEvent +      if suppressPropagation +        DomUtils.suppressPropagation event +        handlerStack.suppressPropagation +      else +        DomUtils.suppressEvent event +        handlerStack.suppressEvent    # Polyfill for selection.type (which is not available in Firefox). -  getSelectionType: (selection) -> +  getSelectionType: (selection = document.getSelection()) ->      selection.type or do ->        if selection.rangeCount == 0          "None" @@ -376,7 +384,7 @@ DomUtils =    # This finds the element containing the selection focus.    getElementWithFocus: (selection, backwards) ->      r = t = selection.getRangeAt 0 -    if selection.type == "Range" +    if DomUtils.getSelectionType(selection) == "Range"        r = t.cloneRange()        r.collapse backwards      t = r.startContainer @@ -416,5 +424,6 @@ DomUtils =        style.textContent = Settings.get "userDefinedLinkHintCss"        document.head.appendChild style -root = exports ? window +root = exports ? (window.root ?= {})  root.DomUtils = DomUtils +extend window, root unless exports? diff --git a/lib/find_mode_history.coffee b/lib/find_mode_history.coffee index ff660bd2..93698266 100644 --- a/lib/find_mode_history.coffee +++ b/lib/find_mode_history.coffee @@ -46,5 +46,6 @@ FindModeHistory =    refreshRawQueryList: (query, rawQueryList) ->      ([ query ].concat rawQueryList.filter (q) => q != query)[0..@max] -root = exports ? window +root = exports ? (window.root ?= {})  root.FindModeHistory = FindModeHistory +extend window, root unless exports? diff --git a/lib/handler_stack.coffee b/lib/handler_stack.coffee index 806b707f..a43fc356 100644 --- a/lib/handler_stack.coffee +++ b/lib/handler_stack.coffee @@ -1,4 +1,4 @@ -root = exports ? window +root = exports ? (window.root ?= {})  class HandlerStack    constructor: -> @@ -57,7 +57,10 @@ class HandlerStack          if result == @passEventToPage            return true          else if result == @suppressPropagation -          DomUtils.suppressPropagation event +          if type == "keydown" +            DomUtils.consumeKeyup event, null, true +          else +            DomUtils.suppressPropagation event            return false          else if result == @restartBubbling            return @bubbleEvent type, event @@ -65,7 +68,11 @@ class HandlerStack            true # Do nothing, but continue bubbling.          else            # result is @suppressEvent or falsy. -          DomUtils.suppressEvent event if @isChromeEvent event +          if @isChromeEvent event +            if type == "keydown" +              DomUtils.consumeKeyup event +            else +              DomUtils.suppressEvent event            return false      # None of our handlers care about this event, so pass it to the page. @@ -120,3 +127,4 @@ class HandlerStack  root.HandlerStack = HandlerStack  root.handlerStack = new HandlerStack() +extend window, root unless exports? diff --git a/lib/keyboard_utils.coffee b/lib/keyboard_utils.coffee index e14e8b3e..09623e50 100644 --- a/lib/keyboard_utils.coffee +++ b/lib/keyboard_utils.coffee @@ -5,7 +5,7 @@ Utils?.monitorChromeStorage "mapKeyRegistry", (value) => mapKeyRegistry = value  KeyboardUtils =    # This maps event.key key names to Vimium key names.    keyNames: -    "ArrowLeft": "left", "ArrowUp": "up", "ArrowRight": "right", "ArrowDown": "down", " ": "space", "Backspace": "backspace" +    "ArrowLeft": "left", "ArrowUp": "up", "ArrowRight": "right", "ArrowDown": "down", " ": "space"    init: ->      if (navigator.userAgent.indexOf("Mac") != -1) @@ -31,19 +31,17 @@ KeyboardUtils =        else if key.length == 1 and not event.shiftKey          key = key.toLowerCase() -    if key of @keyNames -      @keyNames[key]      # It appears that key is not always defined (see #2453). -    else if not key? +    unless key        "" +    else if key of @keyNames +      @keyNames[key] +    else if @isModifier event +      "" # Don't resolve modifier keys.      else if key.length == 1        key -    else if key.length == 2 and "F1" <= key <= "F9" -      key.toLowerCase() # F1 to F9. -    else if key.length == 3 and "F10" <= key <= "F12" -      key.toLowerCase() # F10 to F12.      else -      "" +      key.toLowerCase()    getKeyCharString: (event) ->      if keyChar = @getKeyChar event @@ -60,9 +58,13 @@ KeyboardUtils =        keyChar = mapKeyRegistry[keyChar] ? keyChar        keyChar -  isEscape: (event) -> -    # <c-[> is mapped to Escape in Vim by default. -    event.key == "Escape" || @getKeyCharString(event) == "<c-[>" +  isEscape: do -> +    useVimLikeEscape = true +    Utils.monitorChromeStorage "useVimLikeEscape", (value) -> useVimLikeEscape = value + +    (event) -> +      # <c-[> is mapped to Escape in Vim by default. +      event.key == "Escape" or (useVimLikeEscape and @getKeyCharString(event) == "<c-[>")    isBackspace: (event) ->      event.key in ["Backspace", "Delete"] @@ -70,6 +72,9 @@ KeyboardUtils =    isPrintable: (event) ->      @getKeyCharString(event)?.length == 1 +  isModifier: (event) -> +    event.key in ["Control", "Shift", "Alt", "OS", "AltGraph", "Meta"] +    enUsTranslations:      "Backquote":     ["`", "~"]      "Minus":         ["-", "_"] @@ -97,5 +102,6 @@ KeyboardUtils =  KeyboardUtils.init() -root = exports ? window +root = exports ? (window.root ?= {})  root.KeyboardUtils = KeyboardUtils +extend window, root unless exports? diff --git a/lib/rect.coffee b/lib/rect.coffee index d4807cc2..0e9c3417 100644 --- a/lib/rect.coffee +++ b/lib/rect.coffee @@ -67,12 +67,18 @@ Rect =      rects.filter (rect) -> rect.height > 0 and rect.width > 0 -  contains: (rect1, rect2) -> +  # Determine whether two rects overlap. +  intersects: (rect1, rect2) ->      rect1.right > rect2.left and      rect1.left < rect2.right and      rect1.bottom > rect2.top and      rect1.top < rect2.bottom +  # Determine whether two rects overlap, including 0-width intersections at borders. +  intersectsStrict: (rect1, rect2) -> +    rect1.right >= rect2.left and rect1.left <= rect2.right and +    rect1.bottom >= rect2.top and rect1.top <= rect2.bottom +    equals: (rect1, rect2) ->      for property in ["top", "bottom", "left", "right", "width", "height"]        return false if rect1[property] != rect2[property] @@ -82,14 +88,6 @@ Rect =      @create (Math.max rect1.left, rect2.left), (Math.max rect1.top, rect2.top),          (Math.min rect1.right, rect2.right), (Math.min rect1.bottom, rect2.bottom) -  # Determine whether two rects overlap. -  rectsOverlap: do -> -    halfOverlapChecker = (rect1, rect2) -> -      (rect1.left <= rect2.left <= rect1.right or rect1.left <= rect2.right <= rect1.right) and -        (rect1.top <= rect2.top <= rect1.bottom or rect1.top <= rect2.bottom <= rect1.bottom) - -    (rect1, rect2) -> -      halfOverlapChecker(rect1, rect2) or halfOverlapChecker rect2, rect1 - -root = exports ? window +root = exports ? (window.root ?= {})  root.Rect = Rect +extend window, root unless exports? diff --git a/lib/settings.coffee b/lib/settings.coffee index 38718990..fd1ef268 100644 --- a/lib/settings.coffee +++ b/lib/settings.coffee @@ -202,7 +202,7 @@ Settings.init()  # Perform migration from old settings versions, if this is the background page.  if Utils.isBackgroundPage() -  Settings.onLoaded -> +  Settings.applyMigrations = ->      unless Settings.get "settingsVersion"        # This is a new install.  For some settings, we retain a legacy default behaviour for existing users but        # use a non-default behaviour for new users. @@ -218,5 +218,8 @@ if Utils.isBackgroundPage()      # be removed after 1.58 has been out for sufficiently long.      Settings.nuke "copyNonDefaultsToChromeStorage-20150717" -root = exports ? window +  Settings.onLoaded Settings.applyMigrations.bind Settings + +root = exports ? (window.root ?= {})  root.Settings = Settings +extend window, root unless exports? diff --git a/lib/utils.coffee b/lib/utils.coffee index d0a82cf7..6f38be8f 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -32,14 +32,6 @@ Utils =    # Returns true whenever the current page is the extension's background page.    isBackgroundPage: -> @isExtensionPage() and chrome.extension.getBackgroundPage?() == window -  # Takes a dot-notation object string and calls the function that it points to with the correct value for -  # 'this'. -  invokeCommandString: (str, args...) -> -    [names..., name] = str.split '.' -    obj = window -    obj = obj[component] for component in names -    obj[name].apply obj, args -    # Escape all special characters, so RegExp will parse the string 'as is'.    # Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex    escapeRegexSpecialCharacters: do -> @@ -335,8 +327,11 @@ class JobRunner    onReady: (callback) ->      @fetcher.use callback -root = exports ? window +root = exports ? (window.root ?= {})  root.Utils = Utils  root.SimpleCache = SimpleCache  root.AsyncDataFetcher = AsyncDataFetcher  root.JobRunner = JobRunner +unless exports? +  root.extend = extend +  extend window, root diff --git a/manifest.json b/manifest.json index a9629530..ee5b0655 100644 --- a/manifest.json +++ b/manifest.json @@ -1,18 +1,18 @@  {    "manifest_version": 2,    "name": "Vimium", -  "version": "1.60.4", +  "version": "1.61.1",    "description": "The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.",    "icons": {  "16": "icons/icon16.png",                "48": "icons/icon48.png",               "128": "icons/icon128.png" }, +  "minimum_chrome_version": "51.0",    "background": {      "scripts": [        "lib/utils.js",        "lib/settings.js",        "background_scripts/bg_utils.js",        "background_scripts/commands.js", -      "lib/clipboard.js",        "background_scripts/exclusions.js",        "background_scripts/completion_engines.js",        "background_scripts/completion_search.js", @@ -31,6 +31,7 @@      "bookmarks",      "history",      "clipboardRead", +    "clipboardWrite",      "storage",      "sessions",      "notifications", @@ -60,6 +61,7 @@               "content_scripts/mode_key_handler.js",               "content_scripts/mode_visual.js",               "content_scripts/hud.js", +             "content_scripts/mode_normal.js",               "content_scripts/vimium_frontend.js"              ],        "css": ["content_scripts/vimium.css"], diff --git a/pages/blank.html b/pages/blank.html index 8f10c7f6..d026912e 100644 --- a/pages/blank.html +++ b/pages/blank.html @@ -19,6 +19,7 @@      <script src="../content_scripts/mode_key_handler.js"></script>      <script src="../content_scripts/mode_visual.js"></script>      <script src="../content_scripts/hud.js"></script> +    <script src="../content_scripts/mode_normal.js"></script>      <script src="../content_scripts/vimium_frontend.js"></script>      <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> diff --git a/pages/completion_engines.html b/pages/completion_engines.html index 0c86edf7..3313b26c 100644 --- a/pages/completion_engines.html +++ b/pages/completion_engines.html @@ -22,6 +22,7 @@      <script src="../content_scripts/mode_key_handler.js"></script>      <script src="../content_scripts/mode_visual.js"></script>      <script src="../content_scripts/hud.js"></script> +    <script src="../content_scripts/mode_normal.js"></script>      <script src="../content_scripts/vimium_frontend.js"></script>      <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> diff --git a/pages/help_dialog.coffee b/pages/help_dialog.coffee index f36155e4..08180a72 100644 --- a/pages/help_dialog.coffee +++ b/pages/help_dialog.coffee @@ -83,7 +83,7 @@ HelpDialog =                commandNameElement.textContent = command.command                commandNameElement.title = "Click to copy \"#{command.command}\" to clipboard."                commandNameElement.addEventListener "click", -> -                chrome.runtime.sendMessage handler: "copyToClipboard", data: commandNameElement.textContent +                HUD.copyToClipboard commandNameElement.textContent                  HUD.showForDuration("Yanked #{commandNameElement.textContent}.", 2000)        @showAdvancedCommands(@getShowAdvancedCommands()) diff --git a/pages/help_dialog.html b/pages/help_dialog.html index 1da54efd..7f053265 100644 --- a/pages/help_dialog.html +++ b/pages/help_dialog.html @@ -19,6 +19,7 @@      <script src="../content_scripts/mode_key_handler.js"></script>      <script src="../content_scripts/mode_visual.js"></script>      <script src="../content_scripts/hud.js"></script> +    <script src="../content_scripts/mode_normal.js"></script>      <script src="../content_scripts/vimium_frontend.js"></script>      <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> diff --git a/pages/hud.coffee b/pages/hud.coffee index 0d2ec2f7..5ff2e07e 100644 --- a/pages/hud.coffee +++ b/pages/hud.coffee @@ -95,5 +95,19 @@ handlers =        " (No matches)"      countElement.textContent = if showMatchText then countText else "" +  copyToClipboard: (data) -> +    focusedElement = document.activeElement +    Clipboard.copy data +    focusedElement?.focus() +    window.parent.focus() +    UIComponentServer.postMessage {name: "unfocusIfFocused"} + +  pasteFromClipboard: -> +    focusedElement = document.activeElement +    data = Clipboard.paste() +    focusedElement?.focus() +    window.parent.focus() +    UIComponentServer.postMessage {name: "pasteResponse", data} +  UIComponentServer.registerHandler ({data}) -> handlers[data.name ? data]? data  FindModeHistory.init() diff --git a/pages/hud.html b/pages/hud.html index 3e8cf976..7bd27171 100644 --- a/pages/hud.html +++ b/pages/hud.html @@ -7,6 +7,7 @@      <script type="text/javascript" src="../lib/settings.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="../lib/clipboard.js"></script>      <script type="text/javascript" src="ui_component_server.js"></script>      <script type="text/javascript" src="hud.js"></script>    </head> diff --git a/pages/logging.html b/pages/logging.html index 6eff58c4..17aafd70 100644 --- a/pages/logging.html +++ b/pages/logging.html @@ -19,6 +19,7 @@      <script src="../content_scripts/mode_key_handler.js"></script>      <script src="../content_scripts/mode_visual.js"></script>      <script src="../content_scripts/hud.js"></script> +    <script src="../content_scripts/mode_normal.js"></script>      <script src="../content_scripts/vimium_frontend.js"></script>      <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> diff --git a/pages/options.coffee b/pages/options.coffee index 035dd403..86b6122d 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -39,9 +39,14 @@ class Option      bgSettings.clear @field      @fetch() +  @onSaveCallbacks: [] +  @onSave: (callback) -> +    @onSaveCallbacks.push callback +    # Static method.    @saveOptions: ->      Option.all.map (option) -> option.save() +    callback() for callback in @onSaveCallbacks    # Abstract method; only implemented in sub-classes.    # Populate the option's DOM element (@element) with the setting's current value. @@ -93,8 +98,10 @@ class ExclusionRulesOption extends Option        element    populateElement: (rules) -> -    for rule in rules -      @appendRule rule +    # For the case of restoring a backup, we first have to remove existing rules. +    exclusionRules = $ "exclusionRules" +    exclusionRules.deleteRow 1 while exclusionRules.rows[1] +    @appendRule rule for rule in rules    # Append a row for a new rule.  Return the newly-added element.    appendRule: (rule) -> @@ -323,6 +330,52 @@ document.addEventListener "DOMContentLoaded", ->    xhr.send() +# +# Backup and restore. "?" is for the tests." +DomUtils?.documentReady -> +  restoreSettingsVersion = null + +  populateBackupLinkUrl = -> +    backup = settingsVersion: bgSettings.get "settingsVersion" +    for option in Option.all +      backup[option.field] = option.readValueFromElement() +    # Create the blob in the background page so it isn't garbage collected when the page closes in FF. +    bgWin = chrome.extension.getBackgroundPage() +    blob = new bgWin.Blob [ JSON.stringify backup, null, 2 ] +    $("backupLink").href = bgWin.URL.createObjectURL blob + +  $("backupLink").addEventListener "mousedown", populateBackupLinkUrl, true + +  $("chooseFile").addEventListener "change", (event) -> +    document.activeElement?.blur() +    files = event.target.files +    if files.length == 1 +      file = files[0] +      reader = new FileReader +      reader.readAsText file +      reader.onload = (event) -> +        try +          backup = JSON.parse reader.result +        catch +          alert "Failed to parse Vimium backup." +          return + +        restoreSettingsVersion = backup["settingsVersion"] if "settingsVersion" of backup +        for option in Option.all +          if option.field of backup +            option.populateElement backup[option.field] +            option.onUpdated() + +  Option.onSave -> +    # If we're restoring a backup, then restore the backed up settingsVersion. +    if restoreSettingsVersion? +      bgSettings.set "settingsVersion", restoreSettingsVersion +      restoreSettingsVersion = null +    # Reset the restore-backup input. +    $("chooseFile").value = "" +    # We need to apply migrations in case we are restoring an old backup. +    bgSettings.applyMigrations() +  # Exported for tests.  root = exports ? window  extend root, {Options, isVimiumOptionsPage: true} diff --git a/pages/options.css b/pages/options.css index 5e2a3dfc..dab342a3 100644 --- a/pages/options.css +++ b/pages/options.css @@ -231,3 +231,6 @@ input.pattern, input.passKeys, .exclusionHeaderText {    white-space: nowrap;    width: 110px;  } +#backupLink { +  cursor: pointer; +} diff --git a/pages/options.html b/pages/options.html index 46307b6f..b118bbd9 100644 --- a/pages/options.html +++ b/pages/options.html @@ -20,6 +20,7 @@      <script src="../content_scripts/mode_key_handler.js"></script>      <script src="../content_scripts/mode_visual.js"></script>      <script src="../content_scripts/hud.js"></script> +    <script src="../content_scripts/mode_normal.js"></script>      <script src="../content_scripts/vimium_frontend.js"></script>      <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> @@ -66,7 +67,7 @@ unmapAll                  <a href="#" id="showCommands">Show available commands</a>.                </div>              </div> -            <textarea id="keyMappings" type="text"></textarea> +            <textarea id="keyMappings" type="text" tabIndex="1"></textarea>            </td>          </tr>          <tr> @@ -271,11 +272,12 @@ b: http://b.com/?q=%s description              </td>            </tr>            <tr> -            <td class="caption">CSS for link hints</td> +            <td class="caption">CSS for Vimium UI</td>              <td verticalAlign="top">                <div class="help">                  <div class="example"> -                  The CSS used to style the characters next to each link hint.<br/><br/> +                  These styles are applied to link hints, the Vomnibar, the help dialog, the exclusions pop-up and the HUD.<br /> +                  By default, this CSS is used to style the characters next to each link hint.<br/><br/>                    These styles are used in addition to and take precedence over Vimium's                    default styles.                  </div> @@ -315,6 +317,33 @@ b: http://b.com/?q=%s description            </tr>            -->          </tbody> +        <tbody id='backupAndRestore'> +          <tr> +            <td colspan="2"><header>Backup and Restore</header></td> +          </tr> +          <tr> +            <td class="caption">Backup</td> +            <td> +                <div class="help"> +                  <div class="example"> +                    Click to backup your settings, or right-click and select <i>Save As</i>. +                  </div> +                </div> +              <a id="backupLink" download="vimium-options.json">Click to download backup</a> +            </td> +          </tr> +          <tr> +            <td class="caption">Restore</td> +            <td> +                <div class="help"> +                  <div class="example"> +                    Choose a backup file to restore, then click <i>Save Changes</i>, below, to confirm. +                  </div> +                </div> +              <input id="chooseFile" type="file" accept=".json" style="width: 200px;"/> +            </td> +          </tr> +        </tbody>        </table>      </div> diff --git a/tests/dom_tests/dom_tests.coffee b/tests/dom_tests/dom_tests.coffee index a5c85606..6e422d46 100644 --- a/tests/dom_tests/dom_tests.coffee +++ b/tests/dom_tests/dom_tests.coffee @@ -436,29 +436,29 @@ context "Input focus",      document.getElementById("test-div").innerHTML = ""    should "focus the first element", -> -    focusInput 1 +    NormalModeCommands.focusInput 1      assert.equal "first", document.activeElement.id    should "focus the nth element", -> -    focusInput 100 +    NormalModeCommands.focusInput 100      assert.equal "third", document.activeElement.id    should "activate insert mode on the first element", -> -    focusInput 1 +    NormalModeCommands.focusInput 1      assert.isTrue InsertMode.permanentInstance.isActive()    should "activate insert mode on the first element", -> -    focusInput 100 +    NormalModeCommands.focusInput 100      assert.isTrue InsertMode.permanentInstance.isActive()    should "activate the most recently-selected input if the count is 1", -> -    focusInput 3 -    focusInput 1 +    NormalModeCommands.focusInput 3 +    NormalModeCommands.focusInput 1      assert.equal "third", document.activeElement.id    should "not trigger insert if there are no inputs", ->      document.getElementById("test-div").innerHTML = "" -    focusInput 1 +    NormalModeCommands.focusInput 1      assert.isFalse InsertMode.permanentInstance.isActive()  # TODO: these find prev/next link tests could be refactored into unit tests which invoke a function which has @@ -479,7 +479,7 @@ context "Find prev / next links",      <a href='#second'>next page</a>      """      stubSettings "nextPatterns", "next" -    goNext() +    NormalModeCommands.goNext()      assert.equal '#second', window.location.hash    should "match against non-word patterns", -> @@ -487,7 +487,7 @@ context "Find prev / next links",      <a href='#first'>>></a>      """      stubSettings "nextPatterns", ">>" -    goNext() +    NormalModeCommands.goNext()      assert.equal '#first', window.location.hash    should "favor matches with fewer words", -> @@ -496,14 +496,14 @@ context "Find prev / next links",      <a href='#second'>next!</a>      """      stubSettings "nextPatterns", "next" -    goNext() +    NormalModeCommands.goNext()      assert.equal '#second', window.location.hash    should "find link relation in header", ->      document.getElementById("test-div").innerHTML = """      <link rel='next' href='#first'>      """ -    goNext() +    NormalModeCommands.goNext()      assert.equal '#first', window.location.hash    should "favor link relation to text matching", -> @@ -511,14 +511,14 @@ context "Find prev / next links",      <link rel='next' href='#first'>      <a href='#second'>next</a>      """ -    goNext() +    NormalModeCommands.goNext()      assert.equal '#first', window.location.hash    should "match mixed case link relation", ->      document.getElementById("test-div").innerHTML = """      <link rel='Next' href='#first'>      """ -    goNext() +    NormalModeCommands.goNext()      assert.equal '#first', window.location.hash  createLinks = (n) -> diff --git a/tests/dom_tests/dom_tests.html b/tests/dom_tests/dom_tests.html index d2e795d1..37cd43e3 100644 --- a/tests/dom_tests/dom_tests.html +++ b/tests/dom_tests/dom_tests.html @@ -49,6 +49,7 @@      <script type="text/javascript" src="../../content_scripts/mode_key_handler.js"></script>      <script type="text/javascript" src="../../content_scripts/mode_visual.js"></script>      <script type="text/javascript" src="../../content_scripts/hud.js"></script> +    <script type="text/javascript" src="../../content_scripts/mode_normal.js"></script>      <script type="text/javascript" src="../../content_scripts/vimium_frontend.js"></script>      <script type="text/javascript" src="../shoulda.js/shoulda.js"></script> diff --git a/tests/unit_tests/commands_test.coffee b/tests/unit_tests/commands_test.coffee index 0e0be1d6..49dd2570 100644 --- a/tests/unit_tests/commands_test.coffee +++ b/tests/unit_tests/commands_test.coffee @@ -4,6 +4,14 @@ extend global, require "../../background_scripts/bg_utils.js"  global.Settings = {postUpdateHooks: {}, get: (-> ""), set: ->}  {Commands} = require "../../background_scripts/commands.js" +# Include mode_normal to check that all commands have been implemented. +global.KeyHandlerMode = global.Mode = {} +global.KeyboardUtils = {platform: ""} +extend global, require "../../content_scripts/link_hints.js" +extend global, require "../../content_scripts/marks.js" +extend global, require "../../content_scripts/vomnibar.js" +{NormalModeCommands} = require "../../content_scripts/mode_normal.js" +  context "Key mappings",    setup ->      @testKeySequence = (key, expectedKeyText, expectedKeyLength) -> @@ -114,6 +122,14 @@ context "Parse commands",      assert.equal "a", BgUtils.parseLines("  a  \n  b")[0]      assert.equal "b", BgUtils.parseLines("  a  \n  b")[1] -# TODO (smblott) More tests: -# - Ensure each background command has an implmentation in BackgroundCommands -# - Ensure each foreground command has an implmentation in vimium_frontent.coffee +context "Commands implemented", +  (for own command, options of Commands.availableCommands +    do (command, options) -> +      if options.background +        should "#{command} (background command)", -> +          # TODO: Import background_scripts/main.js and expose BackgroundCommands from there. +          # assert.isTrue BackgroundCommands[command] +      else +        should "#{command} (foreground command)", -> +          assert.isTrue NormalModeCommands[command] +  )... diff --git a/tests/unit_tests/handler_stack_test.coffee b/tests/unit_tests/handler_stack_test.coffee index 7b62af07..374c235b 100644 --- a/tests/unit_tests/handler_stack_test.coffee +++ b/tests/unit_tests/handler_stack_test.coffee @@ -4,6 +4,7 @@ extend(global, require "../../lib/handler_stack.js")  context "handlerStack",    setup ->      stub global, "DomUtils", {} +    stub DomUtils, "consumeKeyup", ->      stub DomUtils, "suppressEvent", ->      stub DomUtils, "suppressPropagation", ->      @handlerStack = new HandlerStack diff --git a/tests/unit_tests/rect_test.coffee b/tests/unit_tests/rect_test.coffee index 0773dbcf..5054e029 100644 --- a/tests/unit_tests/rect_test.coffee +++ b/tests/unit_tests/rect_test.coffee @@ -201,7 +201,7 @@ context "Rect subtraction",              subtractRect = Rect.create x, y, (x + width), (y + height)              resultRects = Rect.subtract rect, subtractRect              for resultRect in resultRects -              assert.isFalse Rect.contains subtractRect, resultRect +              assert.isFalse Rect.intersects subtractRect, resultRect    should "be contained in original rect", ->      rect = Rect.create 0, 0, 3, 3 @@ -212,7 +212,7 @@ context "Rect subtraction",              subtractRect = Rect.create x, y, (x + width), (y + height)              resultRects = Rect.subtract rect, subtractRect              for resultRect in resultRects -              assert.isTrue Rect.contains rect, resultRect +              assert.isTrue Rect.intersects rect, resultRect    should "contain the  subtracted rect in the original minus the results", ->      rect = Rect.create 0, 0, 3, 3 @@ -229,60 +229,60 @@ context "Rect subtraction",              assert.isTrue (resultComplement.length == 0 or resultComplement.length == 1)              if resultComplement.length == 1                complementRect = resultComplement[0] -              assert.isTrue Rect.contains subtractRect, complementRect +              assert.isTrue Rect.intersects subtractRect, complementRect  context "Rect overlaps",    should "detect that a rect overlaps itself", ->      rect = Rect.create 2, 2, 4, 4 -    assert.isTrue Rect.rectsOverlap rect, rect +    assert.isTrue Rect.intersectsStrict rect, rect    should "detect that non-overlapping rectangles do not overlap on the left", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 0, 2, 1, 4 -    assert.isFalse Rect.rectsOverlap rect1, rect2 +    assert.isFalse Rect.intersectsStrict rect1, rect2    should "detect that non-overlapping rectangles do not overlap on the right", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 5, 2, 6, 4 -    assert.isFalse Rect.rectsOverlap rect1, rect2 +    assert.isFalse Rect.intersectsStrict rect1, rect2    should "detect that non-overlapping rectangles do not overlap on the top", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 2, 0, 2, 1 -    assert.isFalse Rect.rectsOverlap rect1, rect2 +    assert.isFalse Rect.intersectsStrict rect1, rect2    should "detect that non-overlapping rectangles do not overlap on the bottom", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 2, 5, 2, 6 -    assert.isFalse Rect.rectsOverlap rect1, rect2 +    assert.isFalse Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles on the left", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 0, 2, 2, 4 -    assert.isTrue Rect.rectsOverlap rect1, rect2 +    assert.isTrue Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles on the right", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 4, 2, 5, 4 -    assert.isTrue Rect.rectsOverlap rect1, rect2 +    assert.isTrue Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles on the top", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 2, 4, 4, 5 -    assert.isTrue Rect.rectsOverlap rect1, rect2 +    assert.isTrue Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles on the bottom", ->      rect1 = Rect.create 2, 2, 4, 4      rect2 = Rect.create 2, 0, 4, 2 -    assert.isTrue Rect.rectsOverlap rect1, rect2 +    assert.isTrue Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles when second rectangle is contained in first", ->      rect1 = Rect.create 1, 1, 4, 4      rect2 = Rect.create 2, 2, 3, 3 -    assert.isTrue Rect.rectsOverlap rect1, rect2 +    assert.isTrue Rect.intersectsStrict rect1, rect2    should "detect overlapping rectangles when first rectangle is contained in second", ->      rect1 = Rect.create 1, 1, 4, 4      rect2 = Rect.create 2, 2, 3, 3 -    assert.isTrue Rect.rectsOverlap rect2, rect1 +    assert.isTrue Rect.intersectsStrict rect2, rect1 diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee index cc1081dd..2794a6d7 100644 --- a/tests/unit_tests/utils_test.coffee +++ b/tests/unit_tests/utils_test.coffee @@ -138,26 +138,6 @@ context "distinctCharacters",    should "eliminate duplicate characters", ->      assert.equal "abc", Utils.distinctCharacters "bbabaabbacabbbab" -context "invokeCommandString", -  setup -> -    @beenCalled = false -    window.singleComponentCommand = => @beenCalled = true -    window.twoComponentCommand = command: window.singleComponentCommand - -  tearDown -> -    delete window.singleComponentCommand -    delete window.twoComponentCommand - -  should "invoke single-component commands", -> -    assert.isFalse @beenCalled -    Utils.invokeCommandString "singleComponentCommand" -    assert.isTrue @beenCalled - -  should "invoke multi-component commands", -> -    assert.isFalse @beenCalled -    Utils.invokeCommandString "twoComponentCommand.command" -    assert.isTrue @beenCalled -  context "escapeRegexSpecialCharacters",    should "escape regexp special characters", ->      str = "-[]/{}()*+?.^$|" | 
