diff options
Diffstat (limited to 'content_scripts')
| -rw-r--r-- | content_scripts/scroller.coffee | 254 | ||||
| -rw-r--r-- | content_scripts/vimium.css | 24 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 41 |
3 files changed, 228 insertions, 91 deletions
diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index f3c632b3..5eb1c5e2 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -1,93 +1,217 @@ -window.Scroller = root = {} - # # activatedElement is different from document.activeElement -- the latter seems to be reserved mostly for # input elements. This mechanism allows us to decide whether to scroll a div or to scroll the whole document. # activatedElement = null -root.init = -> - handlerStack.push DOMActivate: -> activatedElement = event.target - scrollProperties = x: { axisName: 'scrollLeft' max: 'scrollWidth' - viewSize: 'clientHeight' + viewSize: 'clientWidth' } y: { axisName: 'scrollTop' max: 'scrollHeight' - viewSize: 'clientWidth' + viewSize: 'clientHeight' } -getDimension = (el, direction, name) -> - # the clientSizes of the body are the dimensions of the entire page, but the viewport should only be the - # part visible through the window - if name is 'viewSize' and el is document.body - if direction is 'x' then window.innerWidth else window.innerHeight +# Translate a scroll request into a number (which will be interpreted by `scrollBy` as a relative amount, or +# by `scrollTo` as an absolute amount). :direction must be "x" or "y". :amount may be either a number (in +# which case it is simply returned) or a string. If :amount is a string, then it is either "max" (meaning the +# height or width of element), or "viewSize". In both cases, we look up and return the requested amount, +# either in `element` or in `window`, as appropriate. +getDimension = (el, direction, amount) -> + if Utils.isString amount + name = amount + # the clientSizes of the body are the dimensions of the entire page, but the viewport should only be the + # part visible through the window + if name is 'viewSize' and el is document.body + # TODO(smblott) Should we not be returning the width/height of element, here? + if direction is 'x' then window.innerWidth else window.innerHeight + else + el[scrollProperties[direction][name]] else - el[scrollProperties[direction][name]] + amount -# Chrome does not report scrollHeight accurately for nodes with pseudo-elements of height 0 (bug 110149). -# Therefore we cannot figure out if we have scrolled to the bottom of an element by testing if scrollTop + -# clientHeight == scrollHeight. So just try to increase scrollTop blindly -- if it fails we know we have -# reached the end of the content. -ensureScrollChange = (direction, changeFn) -> +# Perform a scroll. Return true if we successfully scrolled by the requested amount, and false otherwise. +performScroll = (element, direction, amount) -> axisName = scrollProperties[direction].axisName - element = activatedElement - loop - oldScrollValue = element[axisName] - changeFn(element, axisName) - break unless (element[axisName] == oldScrollValue && element != document.body) - lastElement = element - # we may have an orphaned element. if so, just scroll the body element. - element = element.parentElement || document.body - - # if the activated element has been scrolled completely offscreen, subsequent changes in its scroll - # position will not provide any more visual feedback to the user. therefore we deactivate it so that - # subsequent scrolls only move the parent element. + before = element[axisName] + element[axisName] += amount + element[axisName] == amount + before + +# Test whether `element` should be scrolled. E.g. hidden elements should not be scrolled. +shouldScroll = (element, direction) -> + computedStyle = window.getComputedStyle(element) + # Elements with `overflow: hidden` must not be scrolled. + return false if computedStyle.getPropertyValue("overflow-#{direction}") == "hidden" + # Elements which are not visible should not be scrolled. + return false if computedStyle.getPropertyValue("visibility") in ["hidden", "collapse"] + return false if computedStyle.getPropertyValue("display") == "none" + true + +# Test whether element does actually scroll in the direction required when asked to do so. Due to chrome bug +# 110149, scrollHeight and clientHeight cannot be used to reliably determine whether an element will scroll. +# Instead, we scroll the element by 1 or -1 and see if it moved (then put it back). :factor is the factor by +# which :scrollBy and :scrollTo will later scale the scroll amount. :factor can be negative, so we need it +# here in order to decide whether we should test a forward scroll or a backward scroll. +# Bug verified in Chrome 38.0.2125.104. +doesScroll = (element, direction, amount, factor) -> + # amount is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls (only + # gg, G, and friends), amount can be either a string ("max" or "viewSize") or zero. In the former case, + # we're definitely scrolling forwards, so any positive value will do for delta. In the latter, we're + # definitely scrolling backwards, so a delta of -1 will do. For absolute scrolls, factor is always 1. + delta = factor * getDimension(element, direction, amount) || -1 + delta = Math.sign delta # 1 or -1 + performScroll(element, direction, delta) and performScroll(element, direction, -delta) + +# From element and its parents, find the first which we should scroll and which does scroll. +findScrollableElement = (element, direction, amount, factor) -> + while element != document.body and + not (doesScroll(element, direction, amount, factor) and shouldScroll(element, direction)) + element = element.parentElement || document.body + element + +checkVisibility = (element) -> + # If the activated element has been scrolled completely offscreen, then subsequent changes in its scroll + # position will not provide any more visual feedback to the user. Therefore, we deactivate it so that + # subsequent scrolls affect the parent element. rect = activatedElement.getBoundingClientRect() if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) activatedElement = element -# scroll the active element in :direction by :amount * :factor. -# :factor is needed because :amount can take on string values, which scrollBy converts to element dimensions. -root.scrollBy = (direction, amount, factor = 1) -> - # if this is called before domReady, just use the window scroll function - if (!document.body and amount instanceof Number) - if (direction == "x") - window.scrollBy(amount, 0) - else - window.scrollBy(0, amount) - return +# How scrolling is handled by CoreScroller. +# - For jump scrolling, the entire scroll happens immediately. +# - For smooth scrolling with distinct key presses, a separate animator is initiated for each key press. +# Therefore, several animators may be active at the same time. This ensures that two quick taps on `j` +# scroll to the same position as two slower taps. +# - For smooth scrolling with keyboard repeat (continuous scrolling), the most recently-activated animator +# continues scrolling at least until its keyup event is received. We never initiate a new animator on +# keyboard repeat. - if (!activatedElement || !isRendered(activatedElement)) - activatedElement = document.body +# CoreScroller contains the core function (scroll) and logic for relative scrolls. All scrolls are ultimately +# translated to relative scrolls. CoreScroller is not exported. +CoreScroller = + init: (frontendSettings) -> + @settings = frontendSettings + @time = 0 + @lastEvent = null + @keyIsDown = false - ensureScrollChange direction, (element, axisName) -> - if Utils.isString amount - elementAmount = getDimension element, direction, amount - else - elementAmount = amount - elementAmount *= factor - element[axisName] += elementAmount + handlerStack.push + keydown: (event) => + @keyIsDown = true + @lastEvent = event + keyup: => + @keyIsDown = false + @time += 1 -root.scrollTo = (direction, pos) -> - return unless document.body + # Return true if CoreScroller would not initiate a new scroll right now. + wouldNotInitiateScroll: -> @lastEvent?.repeat and @settings.get "smoothScroll" - if (!activatedElement || !isRendered(activatedElement)) - activatedElement = document.body + # Calibration fudge factors for continuous scrolling. The calibration value starts at 1.0. We then + # increase it (until it exceeds @maxCalibration) if we guess that the scroll is too slow, or decrease it + # (until it is less than @minCalibration) if we guess that the scroll is too fast. The cutoff point for + # which guess we make is @calibrationBoundary. We require: 0 < @minCalibration <= 1 <= @maxCalibration. + minCalibration: 0.5 # Controls how much we're willing to slow scrolls down; smaller means more slow down. + maxCalibration: 1.6 # Controls how much we're willing to speed scrolls up; bigger means more speed up. + calibrationBoundary: 150 # Boundary between scrolls which are considered too slow, or too fast. - ensureScrollChange direction, (element, axisName) -> - if Utils.isString pos - elementPos = getDimension element, direction, pos - else - elementPos = pos - element[axisName] = elementPos - -# TODO refactor and put this together with the code in getVisibleClientRect -isRendered = (element) -> - computedStyle = window.getComputedStyle(element, null) - return !(computedStyle.getPropertyValue("visibility") != "visible" || - computedStyle.getPropertyValue("display") == "none") + # Scroll element by a relative amount (a number) in some direction. + scroll: (element, direction, amount) -> + return unless amount + + unless @settings.get "smoothScroll" + # Jump scrolling. + performScroll element, direction, amount + checkVisibility element + return + + # We don't activate new animators on keyboard repeats; rather, the most-recently activated animator + # continues scrolling. + return if @lastEvent?.repeat + + activationTime = ++@time + myKeyIsStillDown = => @time == activationTime and @keyIsDown + + # Store amount's sign and make amount positive; the arithmetic is clearer when amount is positive. + sign = Math.sign amount + amount = Math.abs amount + + # Initial intended scroll duration (in ms). We allow a bit longer for longer scrolls. + duration = Math.max 100, 20 * Math.log amount + + totalDelta = 0 + totalElapsed = 0.0 + calibration = 1.0 + previousTimestamp = null + + animate = (timestamp) => + previousTimestamp ?= timestamp + return requestAnimationFrame(animate) if timestamp == previousTimestamp + + # The elapsed time is typically about 16ms. + elapsed = timestamp - previousTimestamp + totalElapsed += elapsed + previousTimestamp = timestamp + + # The constants in the duration calculation, above, are chosen to provide reasonable scroll speeds for + # distinct keypresses. For continuous scrolls, some scrolls are too slow, and others too fast. Here, we + # speed up the slower scrolls, and slow down the faster scrolls. + if myKeyIsStillDown() and 75 <= totalElapsed and @minCalibration <= calibration <= @maxCalibration + calibration *= 1.05 if 1.05 * calibration * amount < @calibrationBoundary # Speed up slow scrolls. + calibration *= 0.95 if @calibrationBoundary < 0.95 * calibration * amount # Slow down fast scrolls. + + # Calculate the initial delta, rounding up to ensure progress. Then, adjust delta to account for the + # current scroll state. + delta = Math.ceil amount * (elapsed / duration) * calibration + delta = if myKeyIsStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta + + if delta and performScroll element, direction, sign * delta + totalDelta += delta + requestAnimationFrame animate + else + # We're done. + checkVisibility element + + # Launch animator. + requestAnimationFrame animate + +# Scroller contains the two main scroll functions (scrollBy and scrollTo) which are exported to clients. +Scroller = + init: (frontendSettings) -> + handlerStack.push DOMActivate: -> activatedElement = event.target + CoreScroller.init frontendSettings + + # scroll the active element in :direction by :amount * :factor. + # :factor is needed because :amount can take on string values, which scrollBy converts to element dimensions. + scrollBy: (direction, amount, factor = 1) -> + # if this is called before domReady, just use the window scroll function + if (!document.body and amount instanceof Number) + if (direction == "x") + window.scrollBy(amount, 0) + else + window.scrollBy(0, amount) + return + + activatedElement ||= document.body + return unless activatedElement + + # Avoid the expensive scroll calculation if it will not be used. This reduces costs during smooth, + # continuous scrolls, and is just an optimization. + unless CoreScroller.wouldNotInitiateScroll() + element = findScrollableElement activatedElement, direction, amount, factor + elementAmount = factor * getDimension element, direction, amount + CoreScroller.scroll element, direction, elementAmount + + scrollTo: (direction, pos) -> + return unless document.body or activatedElement + activatedElement ||= document.body + + element = findScrollableElement activatedElement, direction, pos, 1 + amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] + CoreScroller.scroll element, direction, amount + +root = exports ? window +root.Scroller = Scroller diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css index 24f229f3..a8ae5583 100644 --- a/content_scripts/vimium.css +++ b/content_scripts/vimium.css @@ -2,6 +2,10 @@ * Many CSS class names in this file use the verbose "vimiumXYZ" as the class name. This is so we don't * use the same CSS class names that the page is using, so the page's CSS doesn't mess with the style of our * Vimium dialogs. + * + * The z-indexes of Vimium elements are very large, because we always want them to show on top. Chrome may + * support up to Number.MAX_VALUE, which is approximately 1.7976e+308. We're using 2**31, which is the max value of a singed 32 bit int. + * Let's use try larger valeus if 2**31 empirically isn't large enough. */ /* @@ -54,7 +58,7 @@ tr.vimiumReset { vertical-align: baseline; white-space: normal; width: auto; - z-index: 99999999; + z-index: 2147483648; } /* Linkhints CSS */ @@ -122,7 +126,8 @@ div#vimiumHelpDialog { top:50px; -webkit-box-shadow: rgba(0, 0, 0, 0.4) 0px 0px 6px; overflow-y: auto; - z-index:99999998; + /* One less than vimiumReset */ + z-index: 2147483647; } div#vimiumHelpDialog a { color:blue; } @@ -233,8 +238,8 @@ div.vimiumHUD { border-radius: 4px 4px 0 0; font-family: "Lucida Grande", "Arial", "Sans"; font-size: 12px; - /* One less than vimium's hint markers, so link hints can be shown e.g. for the panel's close button. */ - z-index: 99999997; + /* One less than vimium's hint markers, so link hints can be shown e.g. for the HUD panel's close button. */ + z-index: 2147483646; text-shadow: 0px 1px 2px #FFF; line-height: 1.0; opacity: 0; @@ -291,7 +296,7 @@ body.vimiumFindMode ::selection { box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8); border: 1px solid #aaa; /* One less than hint markers and the help dialog. */ - z-index: 99999996; + z-index: 2147483645; } #vomnibar input { @@ -352,13 +357,6 @@ body.vimiumFindMode ::selection { padding: 2px 0; } -#vomnibar li .vomnibarIcon { - background-position-y: center; - background-size: 16px; - background-repeat: no-repeat; - padding-left: 20px; -} - #vomnibar li .vomnibarSource { color: #777; margin-right: 4px; @@ -408,5 +406,5 @@ div#vimiumFlash { padding: 1px; background-color: transparent; position: absolute; - z-index: 99999; + z-index: 2147483648; } diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 118f985e..469afe71 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -49,7 +49,7 @@ settings = loadedValues: 0 valuesToLoad: ["scrollStepSize", "linkHintCharacters", "linkHintNumbers", "filterLinkHints", "hideHud", "previousPatterns", "nextPatterns", "findModeRawQuery", "regexFindMode", "userDefinedLinkHintCss", - "helpDialog_showAdvancedCommands"] + "helpDialog_showAdvancedCommands", "smoothScroll"] isLoaded: false eventListeners: {} @@ -101,7 +101,7 @@ initializePreDomReady = -> settings.addEventListener("load", LinkHints.init.bind(LinkHints)) settings.load() - Scroller.init() + Scroller.init settings checkIfEnabledForUrl() @@ -179,19 +179,24 @@ window.addEventListener "focus", -> # Initialization tasks that must wait for the document to be ready. # initializeOnDomReady = -> - registerFrame(window.top == window.self) - enterInsertModeIfElementIsFocused() if isEnabledForUrl # Tell the background page we're in the dom ready state. chrome.runtime.connect({ name: "domReady" }) -registerFrame = (is_top) -> - chrome.runtime.sendMessage( - handler: "registerFrame" +registerFrame = -> + # Don't register frameset containers; focusing them is no use. + if document.body.tagName != "FRAMESET" + chrome.runtime.sendMessage + handler: "registerFrame" + frameId: frameId + +# Unregister the frame if we're going to exit. +unregisterFrame = -> + chrome.runtime.sendMessage + handler: "unregisterFrame" frameId: frameId - is_top: is_top - total: frames.length + 1) + tab_is_closing: window.top == window.self # # Enters insert mode if the currently focused element in the DOM is focusable. @@ -367,7 +372,7 @@ onKeypress = (event) -> else if (!isInsertMode() && !findMode) if (isPassKey keyChar) return undefined - if (currentCompletionKeys.indexOf(keyChar) != -1) + if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar)) DomUtils.suppressEvent(event) keyPort.postMessage({ keyChar:keyChar, frameId:frameId }) @@ -440,7 +445,7 @@ onKeydown = (event) -> else if (!isInsertMode() && !findMode) if (keyChar) - if (currentCompletionKeys.indexOf(keyChar) != -1) + if (currentCompletionKeys.indexOf(keyChar) != -1 or isValidFirstKey(keyChar)) DomUtils.suppressEvent event handledKeydownEvents.push event @@ -502,7 +507,7 @@ refreshCompletionKeys = (response) -> chrome.runtime.sendMessage({ handler: "getCompletionKeys" }, refreshCompletionKeys) isValidFirstKey = (keyChar) -> - validFirstKeys[keyChar] || /[1-9]/.test(keyChar) + validFirstKeys[keyChar] || /^[1-9]/.test(keyChar) onFocusCapturePhase = (event) -> if (isFocusable(event.target) && !findMode) @@ -529,6 +534,7 @@ isEmbed = (element) -> ["embed", "object"].indexOf(element.nodeName.toLowerCase( # any element which makes it a rich text editor, like the notes on jjot.com. # isEditable = (target) -> + # Note: document.activeElement.isContentEditable is also rechecked in isInsertMode() dynamically. return true if target.isContentEditable nodeName = target.nodeName.toLowerCase() # use a blacklist instead of a whitelist because new form controls are still being implemented for html5 @@ -552,6 +558,7 @@ window.enterInsertMode = (target) -> # when the last editable element that came into focus -- which insertModeLock points to -- has been blurred. # If insert mode is entered manually (via pressing 'i'), then we set insertModeLock to 'undefined', and only # leave insert mode when the user presses <ESC>. +# Note. This returns the truthiness of target, which is required by isInsertMode. # enterInsertModeWithoutShowingIndicator = (target) -> insertModeLock = target @@ -560,7 +567,13 @@ exitInsertMode = (target) -> insertModeLock = null HUD.hide() -isInsertMode = -> insertModeLock != null +isInsertMode = -> + return true if insertModeLock != null + # Some sites (e.g. inbox.google.com) change the contentEditable attribute on the fly (see #1245); and + # unfortunately, isEditable() is called *before* the change is made. Therefore, we need to re-check whether + # the active element is contentEditable. + document.activeElement and document.activeElement.isContentEditable and + enterInsertModeWithoutShowingIndicator document.activeElement # should be called whenever rawQuery is modified. updateFindModeQuery = -> @@ -1055,6 +1068,8 @@ Tween = state.onUpdate(value) initializePreDomReady() +window.addEventListener("DOMContentLoaded", registerFrame) +window.addEventListener("unload", unregisterFrame) window.addEventListener("DOMContentLoaded", initializeOnDomReady) window.onbeforeunload = -> |
