From 1f678d685eb39e5269cfe8f87ac99522aa1b5200 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Fri, 7 Nov 2014 10:58:39 +0000 Subject: Smooth scrolling. --- background_scripts/settings.coffee | 1 + content_scripts/scroller.coffee | 83 ++++++++++++++++++++++++++++------ content_scripts/vimium_frontend.coffee | 12 ++--- pages/options.coffee | 1 + pages/options.html | 9 ++++ 5 files changed, 85 insertions(+), 21 deletions(-) diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index d6e8fcde..f68a51d7 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -61,6 +61,7 @@ root.Settings = Settings = # or strings defaults: scrollStepSize: 60 + smoothScroll: false keyMappings: "# Insert your prefered key mappings here." linkHintCharacters: "sadfjklewcmpgh" linkHintNumbers: "0123456789" diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index b3a14c78..4d1109c9 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -5,8 +5,10 @@ window.Scroller = root = {} # input elements. This mechanism allows us to decide whether to scroll a div or to scroll the whole document. # activatedElement = null +settings = null -root.init = -> +root.init = (frontendSettings) -> + settings = frontendSettings handlerStack.push DOMActivate: -> activatedElement = event.target scrollProperties = @@ -36,11 +38,13 @@ getDimension = (el, direction, name) -> ensureScrollChange = (direction, changeFn) -> axisName = scrollProperties[direction].axisName element = activatedElement + progress = 0 loop oldScrollValue = element[axisName] # Elements with `overflow: hidden` should not be scrolled. overflow = window.getComputedStyle(element).getPropertyValue("overflow-#{direction}") changeFn(element, axisName) unless overflow == "hidden" + progress += element[axisName] - oldScrollValue break unless element[axisName] == oldScrollValue && element != document.body # we may have an orphaned element. if so, just scroll the body element. element = element.parentElement || document.body @@ -51,6 +55,53 @@ ensureScrollChange = (direction, changeFn) -> rect = activatedElement.getBoundingClientRect() if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) activatedElement = element + # Return the amount by which the scroll position has changed. + return progress + +# Scroll by a relative amount in some direction, possibly smoothly. +# The constants below seem to roughly match chrome's scroll speeds for both short and long scrolls. +# TODO(smblott) For very-long scrolls, chrome implements a soft landing; we don't. +doScrollBy = do -> + interval = 10 # Update interval (in ms). + duration = 120 # This must be a multiple of interval (also in ms). + fudgeFactor = 25 + timer = null + + clearTimer = -> + if timer + clearInterval timer + timer = null + + # Allow a bit longer for longer scrolls. + calculateExtraDuration = (amount) -> + extra = fudgeFactor * Math.log Math.abs amount + # Ensure we have a multiple of interval. + return interval * Math.round (extra / interval) + + scroller = (direction,amount) -> + return ensureScrollChange direction, (element, axisName) -> element[axisName] += amount + + (direction,amount,wantSmooth) -> + clearTimer() + + unless wantSmooth and settings.get "smoothScroll" + scroller direction, amount + return + + requiredTicks = (duration + calculateExtraDuration amount) / interval + # Round away from 0, so that we don't leave any requested scroll amount unscrolled. + rounder = (if 0 <= amount then Math.ceil else Math.floor) + delta = rounder(amount / requiredTicks) + + ticks = 0 + ticker = -> + # If we haven't scrolled by the expected amount, then we've hit the top, bottom or side of the activated + # element, so stop scrolling. + if scroller(direction, delta) != delta or ++ticks == requiredTicks + clearTimer() + + timer = setInterval ticker, interval + ticker() # 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. @@ -66,26 +117,28 @@ root.scrollBy = (direction, amount, factor = 1) -> if (!activatedElement || !isRendered(activatedElement)) activatedElement = document.body - ensureScrollChange direction, (element, axisName) -> - if Utils.isString amount - elementAmount = getDimension element, direction, amount - else - elementAmount = amount - elementAmount *= factor - element[axisName] += elementAmount + if Utils.isString amount + elementAmount = getDimension activatedElement, direction, amount + else + elementAmount = amount + elementAmount *= factor + + doScrollBy direction, elementAmount, true -root.scrollTo = (direction, pos) -> +root.scrollTo = (direction, pos, wantSmooth=false) -> return unless document.body if (!activatedElement || !isRendered(activatedElement)) activatedElement = document.body - ensureScrollChange direction, (element, axisName) -> - if Utils.isString pos - elementPos = getDimension element, direction, pos - else - elementPos = pos - element[axisName] = elementPos + if Utils.isString pos + elementPos = getDimension activatedElement, direction, pos + else + elementPos = pos + axisName = scrollProperties[direction].axisName + elementAmount = elementPos - activatedElement[axisName] + + doScrollBy direction, elementAmount, wantSmooth # TODO refactor and put this together with the code in getVisibleClientRect isRendered = (element) -> diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 118f985e..57503565 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() @@ -227,10 +227,10 @@ window.focusThisFrame = (shouldHighlight) -> setTimeout((-> document.body.style.border = borderWas), 200) extend window, - scrollToBottom: -> Scroller.scrollTo "y", "max" - scrollToTop: -> Scroller.scrollTo "y", 0 - scrollToLeft: -> Scroller.scrollTo "x", 0 - scrollToRight: -> Scroller.scrollTo "x", "max" + scrollToBottom: -> Scroller.scrollTo "y", "max", true + scrollToTop: -> Scroller.scrollTo "y", 0, true + scrollToLeft: -> Scroller.scrollTo "x", 0, true + scrollToRight: -> Scroller.scrollTo "x", "max", true scrollUp: -> Scroller.scrollBy "y", -1 * settings.get("scrollStepSize") scrollDown: -> Scroller.scrollBy "y", settings.get("scrollStepSize") scrollPageUp: -> Scroller.scrollBy "y", "viewSize", -1/2 diff --git a/pages/options.coffee b/pages/options.coffee index f5968eb9..3474bcba 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -196,6 +196,7 @@ document.addEventListener "DOMContentLoaded", -> previousPatterns: NonEmptyTextOption regexFindMode: CheckBoxOption scrollStepSize: NumberOption + smoothScroll: CheckBoxOption searchEngines: TextOption searchUrl: NonEmptyTextOption userDefinedLinkHintCss: TextOption diff --git a/pages/options.html b/pages/options.html index 4f037ba5..d6ce2764 100644 --- a/pages/options.html +++ b/pages/options.html @@ -359,6 +359,15 @@ unmapAll + + + + + + Previous patterns -- cgit v1.2.3 From 1010b7868eb59dde70bc6faf8fd6fb2969688e48 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Fri, 7 Nov 2014 14:49:54 +0000 Subject: smooth scroll; fix absolute scrolling of active element. --- content_scripts/scroller.coffee | 47 ++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 4d1109c9..62a930fc 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -23,13 +23,24 @@ scrollProperties = viewSize: 'clientWidth' } -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 +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 + if direction is 'x' then window.innerWidth else window.innerHeight + else + el[scrollProperties[direction][name]] else - el[scrollProperties[direction][name]] + amount + +# Test whether element should be scrolled. +isScrollable = (element, direction) -> + # Elements with `overflow: hidden` should not be scrolled. + overflow = window.getComputedStyle(element).getPropertyValue("overflow-#{direction}") + return false if overflow == "hidden" + return true # 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 + @@ -41,9 +52,7 @@ ensureScrollChange = (direction, changeFn) -> progress = 0 loop oldScrollValue = element[axisName] - # Elements with `overflow: hidden` should not be scrolled. - overflow = window.getComputedStyle(element).getPropertyValue("overflow-#{direction}") - changeFn(element, axisName) unless overflow == "hidden" + changeFn(element, axisName) if isScrollable element, direction progress += element[axisName] - oldScrollValue break unless element[axisName] == oldScrollValue && element != document.body # we may have an orphaned element. if so, just scroll the body element. @@ -117,10 +126,7 @@ root.scrollBy = (direction, amount, factor = 1) -> if (!activatedElement || !isRendered(activatedElement)) activatedElement = document.body - if Utils.isString amount - elementAmount = getDimension activatedElement, direction, amount - else - elementAmount = amount + elementAmount = getDimension activatedElement, direction, amount elementAmount *= factor doScrollBy direction, elementAmount, true @@ -131,14 +137,17 @@ root.scrollTo = (direction, pos, wantSmooth=false) -> if (!activatedElement || !isRendered(activatedElement)) activatedElement = document.body - if Utils.isString pos - elementPos = getDimension activatedElement, direction, pos - else - elementPos = pos + # Find the deepest scrollable element which would move if we scrolled it. This is the element which + # ensureScrollChange will scroll. + # TODO(smblott) We're pretty much copying what ensureScrollChange does here. Refactor. + element = activatedElement axisName = scrollProperties[direction].axisName - elementAmount = elementPos - activatedElement[axisName] + while element != document.body and + (getDimension(element, direction, pos) == element[axisName] or not isScrollable element, direction) + element = element.parentElement || document.body - doScrollBy direction, elementAmount, wantSmooth + amount = getDimension(element,direction,pos) - element[axisName] + doScrollBy direction, amount, wantSmooth # TODO refactor and put this together with the code in getVisibleClientRect isRendered = (element) -> -- cgit v1.2.3 From b8b1644a306c1fe12b5faa5204630eb30f1e64b3 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Sun, 9 Nov 2014 14:33:46 +0000 Subject: Smooth scroll; handle chrome bug and refactor. --- content_scripts/scroller.coffee | 125 ++++++++++++++++++++-------------------- 1 file changed, 62 insertions(+), 63 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 62a930fc..34f9b148 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -36,40 +36,51 @@ getDimension = (el, direction, amount) -> amount # Test whether element should be scrolled. -isScrollable = (element, direction) -> +isScrollAllowed = (element, direction) -> # Elements with `overflow: hidden` should not be scrolled. - overflow = window.getComputedStyle(element).getPropertyValue("overflow-#{direction}") - return false if overflow == "hidden" - return true - -# 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) -> + window.getComputedStyle(element).getPropertyValue("overflow-#{direction}") != "hidden" + +# Test whether element actually scrolls 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. +isScrollPossible = (element, direction, amount, factor) -> + axisName = scrollProperties[direction].axisName + # delta, here, is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls + # (only gg, G, and friends), amount can be either 'max' or zero. In the former case, we're definitely + # scrolling forwards, so any positive value will do for delta. In the latter case, we're definitely + # scrolling backwards, so a delta of -1 will do. + delta = factor * getDimension(element, direction, amount) || -1 + delta = delta / Math.abs delta # 1 or -1 + before = element[axisName] + element[axisName] += delta + after = element[axisName] + element[axisName] = before + before != after + +# Find the element we should and can scroll. +findScrollableElement = (element, direction, amount, factor = 1) -> axisName = scrollProperties[direction].axisName - element = activatedElement - progress = 0 - loop - oldScrollValue = element[axisName] - changeFn(element, axisName) if isScrollable element, direction - progress += element[axisName] - oldScrollValue - break unless element[axisName] == oldScrollValue && element != document.body - # 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. - rect = activatedElement.getBoundingClientRect() - if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) - activatedElement = element + while element != document.body and + not (isScrollPossible(element, direction, amount, factor) and isScrollAllowed(element, direction)) + element = element.parentElement || document.body + element + +performScroll = (element, axisName, amount, checkVisibility = true) -> + before = element[axisName] + element[axisName] += amount + + if checkVisibility + # 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. + rect = activatedElement.getBoundingClientRect() + if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) + activatedElement = element + # Return the amount by which the scroll position has changed. - return progress + element[axisName] - before -# Scroll by a relative amount in some direction, possibly smoothly. -# The constants below seem to roughly match chrome's scroll speeds for both short and long scrolls. -# TODO(smblott) For very-long scrolls, chrome implements a soft landing; we don't. +# Scroll by a relative amount (a number) in some direction, possibly smoothly. doScrollBy = do -> interval = 10 # Update interval (in ms). duration = 120 # This must be a multiple of interval (also in ms). @@ -87,30 +98,27 @@ doScrollBy = do -> # Ensure we have a multiple of interval. return interval * Math.round (extra / interval) - scroller = (direction,amount) -> - return ensureScrollChange direction, (element, axisName) -> element[axisName] += amount - - (direction,amount,wantSmooth) -> + (element, direction, amount, wantSmooth) -> + axisName = scrollProperties[direction].axisName clearTimer() unless wantSmooth and settings.get "smoothScroll" - scroller direction, amount - return + return performScroll element, axisName, amount requiredTicks = (duration + calculateExtraDuration amount) / interval - # Round away from 0, so that we don't leave any requested scroll amount unscrolled. - rounder = (if 0 <= amount then Math.ceil else Math.floor) - delta = rounder(amount / requiredTicks) + # Round away from 0, so that we don't leave any scroll amount unscrolled. + delta = (if 0 <= amount then Math.ceil else Math.floor)(amount / requiredTicks) - ticks = 0 - ticker = -> - # If we haven't scrolled by the expected amount, then we've hit the top, bottom or side of the activated - # element, so stop scrolling. - if scroller(direction, delta) != delta or ++ticks == requiredTicks - clearTimer() + if delta + ticks = 0 + ticker = -> + if performScroll(element, axisName, delta, false) != delta or ++ticks == requiredTicks + # One final call of performScroll to check the visibility of the activated element. + performScroll(element, axisName, 0, true) + clearTimer() - timer = setInterval ticker, interval - ticker() + timer = setInterval ticker, interval + ticker() # 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. @@ -126,28 +134,19 @@ root.scrollBy = (direction, amount, factor = 1) -> if (!activatedElement || !isRendered(activatedElement)) activatedElement = document.body - elementAmount = getDimension activatedElement, direction, amount - elementAmount *= factor - - doScrollBy direction, elementAmount, true + element = findScrollableElement activatedElement, direction, amount, factor + elementAmount = factor * getDimension element, direction, amount + doScrollBy element, direction, elementAmount, true -root.scrollTo = (direction, pos, wantSmooth=false) -> +root.scrollTo = (direction, pos, wantSmooth = false) -> return unless document.body if (!activatedElement || !isRendered(activatedElement)) activatedElement = document.body - # Find the deepest scrollable element which would move if we scrolled it. This is the element which - # ensureScrollChange will scroll. - # TODO(smblott) We're pretty much copying what ensureScrollChange does here. Refactor. - element = activatedElement - axisName = scrollProperties[direction].axisName - while element != document.body and - (getDimension(element, direction, pos) == element[axisName] or not isScrollable element, direction) - element = element.parentElement || document.body - - amount = getDimension(element,direction,pos) - element[axisName] - doScrollBy direction, amount, wantSmooth + element = findScrollableElement activatedElement, direction, pos + amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] + doScrollBy element, direction, amount, wantSmooth # TODO refactor and put this together with the code in getVisibleClientRect isRendered = (element) -> -- cgit v1.2.3 From df521c26fda9b8d3e8c182fc85deaf5b8c723cd4 Mon Sep 17 00:00:00 2001 From: mrmr1993 Date: Mon, 10 Nov 2014 08:27:55 +0000 Subject: Change smooth scrolling to requestAnimationFrame, tidy up scroller code --- content_scripts/scroller.coffee | 128 ++++++++++++++++++---------------------- 1 file changed, 58 insertions(+), 70 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 34f9b148..2e0d08ad 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -1,5 +1,3 @@ -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. @@ -7,10 +5,6 @@ window.Scroller = root = {} activatedElement = null settings = null -root.init = (frontendSettings) -> - settings = frontendSettings - handlerStack.push DOMActivate: -> activatedElement = event.target - scrollProperties = x: { axisName: 'scrollLeft' @@ -37,8 +31,11 @@ getDimension = (el, direction, amount) -> # Test whether element should be scrolled. isScrollAllowed = (element, direction) -> + computedStyle = window.getComputedStyle(element) # Elements with `overflow: hidden` should not be scrolled. - window.getComputedStyle(element).getPropertyValue("overflow-#{direction}") != "hidden" + return computedStyle.getPropertyValue("overflow-#{direction}") != "hidden" and + ["hidden", "collapse"].indexOf(computedStyle.getPropertyValue("visibility")) == -1 and + computedStyle.getPropertyValue("display") != "none" # Test whether element actually scrolls 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 @@ -58,7 +55,7 @@ isScrollPossible = (element, direction, amount, factor) -> before != after # Find the element we should and can scroll. -findScrollableElement = (element, direction, amount, factor = 1) -> +findScrollableElement = (element = document.body, direction, amount, factor = 1) -> axisName = scrollProperties[direction].axisName while element != document.body and not (isScrollPossible(element, direction, amount, factor) and isScrollAllowed(element, direction)) @@ -81,75 +78,66 @@ performScroll = (element, axisName, amount, checkVisibility = true) -> element[axisName] - before # Scroll by a relative amount (a number) in some direction, possibly smoothly. -doScrollBy = do -> - interval = 10 # Update interval (in ms). - duration = 120 # This must be a multiple of interval (also in ms). - fudgeFactor = 25 - timer = null +doScrollBy = (element, direction, amount, wantSmooth) -> + axisName = scrollProperties[direction].axisName - clearTimer = -> - if timer - clearInterval timer - timer = null + unless wantSmooth and settings.get "smoothScroll" + return performScroll element, axisName, amount + + duration = 100 # Duration in ms. + fudgeFactor = 25 # Allow a bit longer for longer scrolls. - calculateExtraDuration = (amount) -> - extra = fudgeFactor * Math.log Math.abs amount - # Ensure we have a multiple of interval. - return interval * Math.round (extra / interval) - - (element, direction, amount, wantSmooth) -> - axisName = scrollProperties[direction].axisName - clearTimer() - - unless wantSmooth and settings.get "smoothScroll" - return performScroll element, axisName, amount - - requiredTicks = (duration + calculateExtraDuration amount) / interval - # Round away from 0, so that we don't leave any scroll amount unscrolled. - delta = (if 0 <= amount then Math.ceil else Math.floor)(amount / requiredTicks) - - if delta - ticks = 0 - ticker = -> - if performScroll(element, axisName, delta, false) != delta or ++ticks == requiredTicks - # One final call of performScroll to check the visibility of the activated element. - performScroll(element, axisName, 0, true) - clearTimer() - - timer = setInterval ticker, interval - ticker() - -# 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) + duration += fudgeFactor * Math.log Math.abs amount + + roundOut = if 0 <= amount then Math.ceil else Math.floor + + # Round away from 0, so that we don't leave any scroll amount unscrolled. + delta = roundOut(amount / duration) + + animatorId = null + start = null + lastTime = null + scrolledAmount = 0 + + animate = (timestamp) -> + start ?= timestamp + + progress = Math.min(timestamp - start, duration) + scrollDelta = roundOut(delta * progress) - scrolledAmount + scrolledAmount += scrollDelta + + if performScroll(element, axisName, scrollDelta, false) != scrollDelta or + progress >= duration + # One final call of performScroll to check the visibility of the activated element. + performScroll(element, axisName, 0, true) + window.cancelAnimationFrame(animatorId) else - window.scrollBy(0, amount) - return + animatorId = window.requestAnimationFrame(animate) + + animatorId = window.requestAnimationFrame(animate) - if (!activatedElement || !isRendered(activatedElement)) - activatedElement = document.body +Scroller = + init: (frontendSettings) -> + settings = frontendSettings + handlerStack.push DOMActivate: -> activatedElement = event.target - element = findScrollableElement activatedElement, direction, amount, factor - elementAmount = factor * getDimension element, direction, amount - doScrollBy element, direction, elementAmount, true + # 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 + return unless document.body -root.scrollTo = (direction, pos, wantSmooth = false) -> - return unless document.body + element = findScrollableElement activatedElement, direction, amount, factor + elementAmount = factor * getDimension element, direction, amount + doScrollBy element, direction, elementAmount, true - if (!activatedElement || !isRendered(activatedElement)) - activatedElement = document.body + scrollTo: (direction, pos, wantSmooth = false) -> + return unless document.body - element = findScrollableElement activatedElement, direction, pos - amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] - doScrollBy element, direction, amount, wantSmooth + element = findScrollableElement activatedElement, direction, pos + amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] + doScrollBy element, direction, amount, wantSmooth -# 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") +root = exports ? window +root.Scroller = Scroller -- cgit v1.2.3 From f577098a9b72d8bde386ee9e558a512dd46442af Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Tue, 11 Nov 2014 05:49:04 +0000 Subject: Smooth scroll; options above the fold. --- pages/options.html | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pages/options.html b/pages/options.html index d6ce2764..84953023 100644 --- a/pages/options.html +++ b/pages/options.html @@ -282,6 +282,15 @@ unmapAll + + + + + + Show advanced options… @@ -359,15 +368,6 @@ unmapAll - - - - - - Previous patterns -- cgit v1.2.3 From d19b7dff273674efbde9a3d5f6992bd4aaab41b0 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Tue, 11 Nov 2014 05:56:03 +0000 Subject: Smooth scroll; revert entry logic. --- content_scripts/scroller.coffee | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 2e0d08ad..1becc523 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -126,14 +126,23 @@ Scroller = # :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 - return unless document.body + 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 element = findScrollableElement activatedElement, direction, amount, factor elementAmount = factor * getDimension element, direction, amount doScrollBy element, direction, elementAmount, true scrollTo: (direction, pos, wantSmooth = false) -> - return unless document.body + return unless document.body or activatedElement + activatedElement ||= document.body element = findScrollableElement activatedElement, direction, pos amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] -- cgit v1.2.3 From 7e807c82bb708ed1e7ed50b2a0c302dda47f1a39 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Tue, 11 Nov 2014 06:52:22 +0000 Subject: Smooth scroll; initial handle keyboard repeat. --- content_scripts/scroller.coffee | 95 ++++++++++++++++++++++++++++------------- 1 file changed, 66 insertions(+), 29 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 1becc523..eba9fafb 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -2,6 +2,7 @@ # 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. # +root = exports ? window activatedElement = null settings = null @@ -78,44 +79,81 @@ performScroll = (element, axisName, amount, checkVisibility = true) -> element[axisName] - before # Scroll by a relative amount (a number) in some direction, possibly smoothly. -doScrollBy = (element, direction, amount, wantSmooth) -> - axisName = scrollProperties[direction].axisName +doScrollBy = do -> + time = 0 + lastActivationId = -1 + keyupHandler = null + + # When the keyboard is repeating and the key is released, sometimes the last key event is delivered *after* + # the keyup event. This could cause indefinite scrolling with no key pressed, because no further keyup + # event is delivered (we behave as if the final key is still pressed). To get around this, we briefly hold + # a lock to prevent a new smooth scrolling event from beginning too soon after the end of the previous + # scroll. This is a hack. + keyLock = false + + (element, direction, amount, wantSmooth) -> + axisName = scrollProperties[direction].axisName + + unless wantSmooth and settings.get "smoothScroll" + return performScroll element, axisName, amount + + # Assumption. If animator A is started before animator B, then A will also finish before B. We need to + # verify this - it may not always be true. + + console.log("locked") if keyLock + if lastActivationId == time or keyLock + # This is a keyboard repeat, and the most-recently activated animator will handle it. + return + + if not keyupHandler + keyupHandler = root.handlerStack.push keyup: -> + time += 1 + keyLock = true + setTimeout((-> keyLock = false), 10) + console.log "keyup", time, lastActivationId + true # Do not prevent propagation. - unless wantSmooth and settings.get "smoothScroll" - return performScroll element, axisName, amount + lastActivationId = activationId = ++time + console.log activationId - duration = 100 # Duration in ms. - fudgeFactor = 25 + duration = 100 # Duration in ms. + fudgeFactor = 25 - # Allow a bit longer for longer scrolls. - duration += fudgeFactor * Math.log Math.abs amount + # Allow a bit longer for longer scrolls. + duration += fudgeFactor * Math.log Math.abs amount - roundOut = if 0 <= amount then Math.ceil else Math.floor + # Round away from 0, so that we don't leave any scroll amount unscrolled. + roundOut = if 0 <= amount then Math.ceil else Math.floor + delta = roundOut(amount / duration) - # Round away from 0, so that we don't leave any scroll amount unscrolled. - delta = roundOut(amount / duration) + animatorId = null + lastTime = null + start = null + scrolledAmount = 0 - animatorId = null - start = null - lastTime = null - scrolledAmount = 0 + stopScrolling = (progress) -> + if activationId == time + # This is the most recent animator, and the key is still pressed. + false + else + duration <= progress - animate = (timestamp) -> - start ?= timestamp + animate = (timestamp) -> + start ?= timestamp - progress = Math.min(timestamp - start, duration) - scrollDelta = roundOut(delta * progress) - scrolledAmount - scrolledAmount += scrollDelta + progress = timestamp - start + scrollDelta = roundOut(delta * progress) - scrolledAmount + scrolledAmount += scrollDelta - if performScroll(element, axisName, scrollDelta, false) != scrollDelta or - progress >= duration - # One final call of performScroll to check the visibility of the activated element. - performScroll(element, axisName, 0, true) - window.cancelAnimationFrame(animatorId) - else - animatorId = window.requestAnimationFrame(animate) + if performScroll(element, axisName, scrollDelta, false) != scrollDelta or stopScrolling progress + # One final call of performScroll to check the visibility of the activated element. + performScroll(element, axisName, 0, true) + window.cancelAnimationFrame(animatorId) + time += 1 if activationId == time + else + animatorId = window.requestAnimationFrame(animate) - animatorId = window.requestAnimationFrame(animate) + animatorId = window.requestAnimationFrame(animate) Scroller = init: (frontendSettings) -> @@ -148,5 +186,4 @@ Scroller = amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] doScrollBy element, direction, amount, wantSmooth -root = exports ? window root.Scroller = Scroller -- cgit v1.2.3 From 6bd7add111cbc3a643c786db2ba18c01c886eb97 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Tue, 11 Nov 2014 11:23:17 +0000 Subject: Smooth scroll; tune key-repeat code. --- content_scripts/scroller.coffee | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index eba9fafb..fc4b922e 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -2,7 +2,6 @@ # 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. # -root = exports ? window activatedElement = null settings = null @@ -84,12 +83,12 @@ doScrollBy = do -> lastActivationId = -1 keyupHandler = null - # When the keyboard is repeating and the key is released, sometimes the last key event is delivered *after* - # the keyup event. This could cause indefinite scrolling with no key pressed, because no further keyup - # event is delivered (we behave as if the final key is still pressed). To get around this, we briefly hold - # a lock to prevent a new smooth scrolling event from beginning too soon after the end of the previous - # scroll. This is a hack. - keyLock = false + # When the keyboard is repeating and then the key is released, sometimes the last key event is delivered + # *after* the keyup event. This could cause indefinite scrolling with no key pressed, because no further + # keyup event is delivered (so we behave as if the key is still pressed). To get around this, we briefly + # hold a lock; this prevents a new smooth scrolling event from starting too soon after the end of the + # previous one. + smoothScrollLock = false (element, direction, amount, wantSmooth) -> axisName = scrollProperties[direction].axisName @@ -97,24 +96,18 @@ doScrollBy = do -> unless wantSmooth and settings.get "smoothScroll" return performScroll element, axisName, amount - # Assumption. If animator A is started before animator B, then A will also finish before B. We need to - # verify this - it may not always be true. - - console.log("locked") if keyLock - if lastActivationId == time or keyLock - # This is a keyboard repeat, and the most-recently activated animator will handle it. + if lastActivationId == time or smoothScrollLock + # This is a keyboard repeat (and the most-recently activated animator -- which is still active -- will + # keep going), or it's too soon to begin smooth scrolling again. return if not keyupHandler keyupHandler = root.handlerStack.push keyup: -> time += 1 - keyLock = true - setTimeout((-> keyLock = false), 10) - console.log "keyup", time, lastActivationId - true # Do not prevent propagation. + setTimeout((-> smoothScrollLock = false), 50) + smoothScrollLock = true # Hereby also returning true, so allowing further propagation of the event. lastActivationId = activationId = ++time - console.log activationId duration = 100 # Duration in ms. fudgeFactor = 25 @@ -131,9 +124,9 @@ doScrollBy = do -> start = null scrolledAmount = 0 - stopScrolling = (progress) -> + shouldStopScrolling = (progress) -> if activationId == time - # This is the most recent animator, and the key is still pressed. + # This is the most recent animator and the key is still pressed, so keep going. false else duration <= progress @@ -145,10 +138,11 @@ doScrollBy = do -> scrollDelta = roundOut(delta * progress) - scrolledAmount scrolledAmount += scrollDelta - if performScroll(element, axisName, scrollDelta, false) != scrollDelta or stopScrolling progress + if performScroll(element, axisName, scrollDelta, false) != scrollDelta or shouldStopScrolling progress # One final call of performScroll to check the visibility of the activated element. performScroll(element, axisName, 0, true) window.cancelAnimationFrame(animatorId) + # Advance time if this is the most-recently activated animator and time hasn't otherwise advanced. time += 1 if activationId == time else animatorId = window.requestAnimationFrame(animate) @@ -186,4 +180,5 @@ Scroller = amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] doScrollBy element, direction, amount, wantSmooth +root = exports ? window root.Scroller = Scroller -- cgit v1.2.3 From edc80d03200ecec78f7c30e7c8b0eb41b545cc55 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Tue, 11 Nov 2014 13:31:00 +0000 Subject: Smooth scroll; eliminate race condition. --- content_scripts/scroller.coffee | 43 ++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index fc4b922e..03be9694 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -77,37 +77,38 @@ performScroll = (element, axisName, amount, checkVisibility = true) -> # Return the amount by which the scroll position has changed. element[axisName] - before +# How scrolling is handled: +# - For non-smooth 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 (roughly) the same position as two slower taps. +# - For smooth scrolling with keyboard repeat, the most recently-activated animator continues scrolling +# until its corresponding keyup event is received. We never initiate a new animator on keyboard repeat. + # Scroll by a relative amount (a number) in some direction, possibly smoothly. doScrollBy = do -> time = 0 - lastActivationId = -1 - keyupHandler = null - - # When the keyboard is repeating and then the key is released, sometimes the last key event is delivered - # *after* the keyup event. This could cause indefinite scrolling with no key pressed, because no further - # keyup event is delivered (so we behave as if the key is still pressed). To get around this, we briefly - # hold a lock; this prevents a new smooth scrolling event from starting too soon after the end of the - # previous one. - smoothScrollLock = false + mostRecentActivationId = -1 + lastEvent = null + keyHandler = null (element, direction, amount, wantSmooth) -> + if not keyHandler + keyHandler = root.handlerStack.push + keydown: (event) -> lastEvent = event + keyup: -> time += 1 + axisName = scrollProperties[direction].axisName unless wantSmooth and settings.get "smoothScroll" return performScroll element, axisName, amount - if lastActivationId == time or smoothScrollLock - # This is a keyboard repeat (and the most-recently activated animator -- which is still active -- will - # keep going), or it's too soon to begin smooth scrolling again. + if mostRecentActivationId == time or lastEvent?.repeat + # Either the most-recently activated animator has not yet received its keyup event (so it's still + # scrolling), or this is a keyboard repeat (for which we don't initiate a new animator). return - if not keyupHandler - keyupHandler = root.handlerStack.push keyup: -> - time += 1 - setTimeout((-> smoothScrollLock = false), 50) - smoothScrollLock = true # Hereby also returning true, so allowing further propagation of the event. - - lastActivationId = activationId = ++time + mostRecentActivationId = activationId = ++time duration = 100 # Duration in ms. fudgeFactor = 25 @@ -126,7 +127,7 @@ doScrollBy = do -> shouldStopScrolling = (progress) -> if activationId == time - # This is the most recent animator and the key is still pressed, so keep going. + # This is the most recently-activated animator and we haven't yet seen its keyup event, so keep going. false else duration <= progress @@ -142,8 +143,6 @@ doScrollBy = do -> # One final call of performScroll to check the visibility of the activated element. performScroll(element, axisName, 0, true) window.cancelAnimationFrame(animatorId) - # Advance time if this is the most-recently activated animator and time hasn't otherwise advanced. - time += 1 if activationId == time else animatorId = window.requestAnimationFrame(animate) -- cgit v1.2.3 From a8776fe42db51133205222ef9220485460150bad Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Tue, 11 Nov 2014 13:49:25 +0000 Subject: Smooth scroll; tidy up. --- content_scripts/scroller.coffee | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 03be9694..8d6ca128 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -30,16 +30,19 @@ getDimension = (el, direction, amount) -> amount # Test whether element should be scrolled. -isScrollAllowed = (element, direction) -> +shouldScroll = (element, direction) -> computedStyle = window.getComputedStyle(element) # Elements with `overflow: hidden` should not be scrolled. - return computedStyle.getPropertyValue("overflow-#{direction}") != "hidden" and - ["hidden", "collapse"].indexOf(computedStyle.getPropertyValue("visibility")) == -1 and - computedStyle.getPropertyValue("display") != "none" - -# Test whether element actually scrolls 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. + return false if computedStyle.getPropertyValue("overflow-#{direction}") == "hidden" + # Non-visible elements should not be scrolled. + return false if ["hidden", "collapse"].indexOf(computedStyle.getPropertyValue("visibility")) != -1 + return false if computedStyle.getPropertyValue("display") == "none" + true + +# Test whether element actually scrolls 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). +# Bug verified in Chrome 38.0.2125.104. isScrollPossible = (element, direction, amount, factor) -> axisName = scrollProperties[direction].axisName # delta, here, is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls @@ -55,10 +58,10 @@ isScrollPossible = (element, direction, amount, factor) -> before != after # Find the element we should and can scroll. -findScrollableElement = (element = document.body, direction, amount, factor = 1) -> +findScrollableElement = (element, direction, amount, factor = 1) -> axisName = scrollProperties[direction].axisName while element != document.body and - not (isScrollPossible(element, direction, amount, factor) and isScrollAllowed(element, direction)) + not (isScrollPossible(element, direction, amount, factor) and shouldScroll(element, direction)) element = element.parentElement || document.body element @@ -87,7 +90,7 @@ performScroll = (element, axisName, amount, checkVisibility = true) -> # Scroll by a relative amount (a number) in some direction, possibly smoothly. doScrollBy = do -> - time = 0 + time = 0 # Logical time. mostRecentActivationId = -1 lastEvent = null keyHandler = null -- cgit v1.2.3 From 553a5c21aef68811d7e9289a68edea11613f93ea Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Tue, 11 Nov 2014 14:41:58 +0000 Subject: Smooth scroll; add comment and remove unused variable. --- content_scripts/scroller.coffee | 1 - 1 file changed, 1 deletion(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 8d6ca128..683e7f99 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -124,7 +124,6 @@ doScrollBy = do -> delta = roundOut(amount / duration) animatorId = null - lastTime = null start = null scrolledAmount = 0 -- cgit v1.2.3 From 9ae1561caac47c8f4739492e66e3986f1977a3cb Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Tue, 11 Nov 2014 18:54:23 +0000 Subject: Smooth scroll; comments and more minor tidy up. --- content_scripts/scroller.coffee | 28 +++++++++++++++------------- content_scripts/vimium_frontend.coffee | 8 ++++---- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 683e7f99..33deb5cf 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -95,20 +95,24 @@ doScrollBy = do -> lastEvent = null keyHandler = null - (element, direction, amount, wantSmooth) -> - if not keyHandler - keyHandler = root.handlerStack.push + (element, direction, amount) -> + return unless amount + + unless keyHandler + keyHandler = handlerStack.push keydown: (event) -> lastEvent = event keyup: -> time += 1 axisName = scrollProperties[direction].axisName - unless wantSmooth and settings.get "smoothScroll" + unless settings.get "smoothScroll" return performScroll element, axisName, amount if mostRecentActivationId == time or lastEvent?.repeat # Either the most-recently activated animator has not yet received its keyup event (so it's still - # scrolling), or this is a keyboard repeat (for which we don't initiate a new animator). + # scrolling), or this is a keyboard repeat (for which we don't initiate a new animator). We need both + # of these checks because sometimes (perhaps one time in twenty) the last keyboard repeat arrives + # *after* the corresponding keyup. return mostRecentActivationId = activationId = ++time @@ -128,11 +132,9 @@ doScrollBy = do -> scrolledAmount = 0 shouldStopScrolling = (progress) -> - if activationId == time - # This is the most recently-activated animator and we haven't yet seen its keyup event, so keep going. - false - else - duration <= progress + # If activationId == time, then this is the most recently-activated animator and we haven't yet seen its + # keyup event, so keep going. + if activationId == time then false else duration <= progress animate = (timestamp) -> start ?= timestamp @@ -171,15 +173,15 @@ Scroller = element = findScrollableElement activatedElement, direction, amount, factor elementAmount = factor * getDimension element, direction, amount - doScrollBy element, direction, elementAmount, true + doScrollBy element, direction, elementAmount - scrollTo: (direction, pos, wantSmooth = false) -> + scrollTo: (direction, pos) -> return unless document.body or activatedElement activatedElement ||= document.body element = findScrollableElement activatedElement, direction, pos amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] - doScrollBy element, direction, amount, wantSmooth + doScrollBy element, direction, amount root = exports ? window root.Scroller = Scroller diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 57503565..9c59396f 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -227,10 +227,10 @@ window.focusThisFrame = (shouldHighlight) -> setTimeout((-> document.body.style.border = borderWas), 200) extend window, - scrollToBottom: -> Scroller.scrollTo "y", "max", true - scrollToTop: -> Scroller.scrollTo "y", 0, true - scrollToLeft: -> Scroller.scrollTo "x", 0, true - scrollToRight: -> Scroller.scrollTo "x", "max", true + scrollToBottom: -> Scroller.scrollTo "y", "max" + scrollToTop: -> Scroller.scrollTo "y", 0 + scrollToLeft: -> Scroller.scrollTo "x", 0 + scrollToRight: -> Scroller.scrollTo "x", "max" scrollUp: -> Scroller.scrollBy "y", -1 * settings.get("scrollStepSize") scrollDown: -> Scroller.scrollBy "y", settings.get("scrollStepSize") scrollPageUp: -> Scroller.scrollBy "y", "viewSize", -1/2 -- cgit v1.2.3 From e9130eb61b7d3742a80e899c0f1afcc23ad7b5ce Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Thu, 13 Nov 2014 07:47:45 +0000 Subject: Smooth scrolling; tidy up, better comments. --- content_scripts/scroller.coffee | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 33deb5cf..8ce87253 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -35,7 +35,7 @@ shouldScroll = (element, direction) -> # Elements with `overflow: hidden` should not be scrolled. return false if computedStyle.getPropertyValue("overflow-#{direction}") == "hidden" # Non-visible elements should not be scrolled. - return false if ["hidden", "collapse"].indexOf(computedStyle.getPropertyValue("visibility")) != -1 + return false if computedStyle.getPropertyValue("visibility") in ["hidden", "collapse"] return false if computedStyle.getPropertyValue("display") == "none" true @@ -73,25 +73,28 @@ performScroll = (element, axisName, amount, checkVisibility = true) -> # 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. + # TODO(smblott) Refactor this out of here. rect = activatedElement.getBoundingClientRect() if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) activatedElement = element - # Return the amount by which the scroll position has changed. - element[axisName] - before + # Return true if we successfully scrolled by the requested amount, false otherwise. + amount == element[axisName] - before # How scrolling is handled: # - For non-smooth 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 (roughly) the same position as two slower taps. +# scroll to the same position as two slower taps (modulo rounding errors). # - For smooth scrolling with keyboard repeat, the most recently-activated animator continues scrolling -# until its corresponding keyup event is received. We never initiate a new animator on keyboard repeat. +# at least until its corresponding keyup event is received. We never initiate a new animator on keyboard +# repeat. # Scroll by a relative amount (a number) in some direction, possibly smoothly. doScrollBy = do -> - time = 0 # Logical time. - mostRecentActivationId = -1 + # This is logical time. Time is advanced each time an animator is activated, and on each keyup event. + time = 0 + mostRecentActivationTime = -1 lastEvent = null keyHandler = null @@ -108,22 +111,19 @@ doScrollBy = do -> unless settings.get "smoothScroll" return performScroll element, axisName, amount - if mostRecentActivationId == time or lastEvent?.repeat + if mostRecentActivationTime == time or lastEvent?.repeat # Either the most-recently activated animator has not yet received its keyup event (so it's still # scrolling), or this is a keyboard repeat (for which we don't initiate a new animator). We need both # of these checks because sometimes (perhaps one time in twenty) the last keyboard repeat arrives # *after* the corresponding keyup. return - mostRecentActivationId = activationId = ++time + mostRecentActivationTime = activationTime = ++time - duration = 100 # Duration in ms. - fudgeFactor = 25 + # Duration in ms. Allow a bit longer for longer scrolls. + duration = 100 + 25 * Math.log Math.abs amount - # Allow a bit longer for longer scrolls. - duration += fudgeFactor * Math.log Math.abs amount - - # Round away from 0, so that we don't leave any scroll amount unscrolled. + # Round away from 0, so that we don't leave any requested scroll amount unscrolled. roundOut = if 0 <= amount then Math.ceil else Math.floor delta = roundOut(amount / duration) @@ -132,9 +132,9 @@ doScrollBy = do -> scrolledAmount = 0 shouldStopScrolling = (progress) -> - # If activationId == time, then this is the most recently-activated animator and we haven't yet seen its - # keyup event, so keep going. - if activationId == time then false else duration <= progress + # If activationTime == time, then this is the most recently-activated animator and we haven't yet seen + # its keyup event, so keep going; otherwise, check progress and duration. + if activationTime == time then false else duration <= progress animate = (timestamp) -> start ?= timestamp @@ -143,7 +143,7 @@ doScrollBy = do -> scrollDelta = roundOut(delta * progress) - scrolledAmount scrolledAmount += scrollDelta - if performScroll(element, axisName, scrollDelta, false) != scrollDelta or shouldStopScrolling progress + if not performScroll(element, axisName, scrollDelta, false) or shouldStopScrolling progress # One final call of performScroll to check the visibility of the activated element. performScroll(element, axisName, 0, true) window.cancelAnimationFrame(animatorId) -- cgit v1.2.3 From edd0c1ca7495a32930ed1170abaedf2c73234581 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Thu, 13 Nov 2014 08:36:34 +0000 Subject: Smooth scrolling; note incorrect calibration; MUST BE FIXED! --- content_scripts/scroller.coffee | 3 +++ 1 file changed, 3 insertions(+) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 8ce87253..07dc9f11 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -139,6 +139,9 @@ doScrollBy = do -> animate = (timestamp) -> start ?= timestamp + # FIXME(smblott) This calculation is not correctly calibrated: + # - A `j` with smooth scrolling does not take you to the same place as a `j` with jump scrolling. + # - A `j` followed by `k` does not take you back to exactly where you started. progress = timestamp - start scrollDelta = roundOut(delta * progress) - scrolledAmount scrolledAmount += scrollDelta -- cgit v1.2.3 From fbf251ee26e528b19afc1b70f80488bc74af0197 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Thu, 13 Nov 2014 12:14:47 +0000 Subject: Smooth scroll; fix displacement and calibrate. --- content_scripts/scroller.coffee | 87 ++++++++++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 07dc9f11..93048907 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -57,7 +57,7 @@ isScrollPossible = (element, direction, amount, factor) -> element[axisName] = before before != after -# Find the element we should and can scroll. +# Find the element which we should and can scroll (or document.body). findScrollableElement = (element, direction, amount, factor = 1) -> axisName = scrollProperties[direction].axisName while element != document.body and @@ -85,10 +85,10 @@ performScroll = (element, axisName, amount, checkVisibility = true) -> # - For non-smooth 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 (modulo rounding errors). -# - For smooth scrolling with keyboard repeat, the most recently-activated animator continues scrolling -# at least until its corresponding keyup event is received. We never initiate a new animator on keyboard -# repeat. +# 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 corresponding keyup event is received. We never initiate a new +# animator on keyboard repeat. # Scroll by a relative amount (a number) in some direction, possibly smoothly. doScrollBy = do -> @@ -103,7 +103,7 @@ doScrollBy = do -> unless keyHandler keyHandler = handlerStack.push - keydown: (event) -> lastEvent = event + keydown: -> lastEvent = event keyup: -> time += 1 axisName = scrollProperties[direction].axisName @@ -120,40 +120,63 @@ doScrollBy = do -> mostRecentActivationTime = activationTime = ++time - # Duration in ms. Allow a bit longer for longer scrolls. - duration = 100 + 25 * Math.log Math.abs amount + isKeyStillDown = -> + time == activationTime + + # Store amount's sign and make amount positive; the logic is clearer when amount is positive. + sign = amount / Math.abs amount + amount = Math.abs amount - # Round away from 0, so that we don't leave any requested scroll amount unscrolled. - roundOut = if 0 <= amount then Math.ceil else Math.floor - delta = roundOut(amount / duration) + # Duration in ms. Allow a bit longer for longer scrolls. + duration = Math.max 100, 20 * Math.log amount + totalDelta = 0 + calibration = 1.0 + initialTimestamp = null + previousTimestamp = null animatorId = null - start = null - scrolledAmount = 0 - shouldStopScrolling = (progress) -> - # If activationTime == time, then this is the most recently-activated animator and we haven't yet seen - # its keyup event, so keep going; otherwise, check progress and duration. - if activationTime == time then false else duration <= progress + progressAnimation = -> + animatorId = requestAnimationFrame animate + + cancelAnimation = -> + # Make one final call of performScroll to check the visibility of the activated element. + performScroll element, axisName, 0, true + cancelAnimationFrame animatorId animate = (timestamp) -> - start ?= timestamp - - # FIXME(smblott) This calculation is not correctly calibrated: - # - A `j` with smooth scrolling does not take you to the same place as a `j` with jump scrolling. - # - A `j` followed by `k` does not take you back to exactly where you started. - progress = timestamp - start - scrollDelta = roundOut(delta * progress) - scrolledAmount - scrolledAmount += scrollDelta - - if not performScroll(element, axisName, scrollDelta, false) or shouldStopScrolling progress - # One final call of performScroll to check the visibility of the activated element. - performScroll(element, axisName, 0, true) - window.cancelAnimationFrame(animatorId) + initialTimestamp ?= timestamp + previousTimestamp ?= timestamp + + if timestamp == previousTimestamp + return progressAnimation() + + # The elapsed time is typically about 16ms. + elapsed = timestamp - previousTimestamp + totalElapsed = timestamp - initialTimestamp + previousTimestamp = timestamp + + # The constants in the duration calculation, above, are chosen to provide reasonable scroll speeds for + # scrolls resulting from distinct keypresses. For continuous scrolls (where the key remains depressed), + # some scrolls are too slow, and others too fast. Here, we compensate a bit. + if isKeyStillDown() and 50 <= totalElapsed and 0.5 <= calibration <= 1.6 + calibration *= 1.05 if 1.05 * calibration * amount <= 150 # Speed up slow scrolls. + calibration *= 0.95 if 150 <= 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 isKeyStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta + + if delta and performScroll element, axisName, sign * delta, false + totalDelta += delta + progressAnimation() else - animatorId = window.requestAnimationFrame(animate) + # We're done. Make one final call of performScroll to check the visibility of the activated element. + performScroll element, axisName, 0, true + cancelAnimationFrame animatorId - animatorId = window.requestAnimationFrame(animate) + progressAnimation() Scroller = init: (frontendSettings) -> -- cgit v1.2.3 From c69b2efaabfb5192bbf3f8488649513953fb7344 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Thu, 13 Nov 2014 15:53:28 +0000 Subject: Smooth scroll; yet more refactoring. --- content_scripts/scroller.coffee | 67 ++++++++++++++++++----------------------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 93048907..c69ca235 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -29,6 +29,12 @@ getDimension = (el, direction, amount) -> else amount +# Perform a scroll. Return true if we successfully scrolled by the requested amount, and false otherwise. +performScroll = (element, axisName, amount) -> + before = element[axisName] + element[axisName] += amount + amount == element[axisName] - before + # Test whether element should be scrolled. shouldScroll = (element, direction) -> computedStyle = window.getComputedStyle(element) @@ -45,17 +51,13 @@ shouldScroll = (element, direction) -> # Bug verified in Chrome 38.0.2125.104. isScrollPossible = (element, direction, amount, factor) -> axisName = scrollProperties[direction].axisName - # delta, here, is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls + # amount, here, is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls # (only gg, G, and friends), amount can be either 'max' or zero. In the former case, we're definitely # scrolling forwards, so any positive value will do for delta. In the latter case, we're definitely # scrolling backwards, so a delta of -1 will do. delta = factor * getDimension(element, direction, amount) || -1 delta = delta / Math.abs delta # 1 or -1 - before = element[axisName] - element[axisName] += delta - after = element[axisName] - element[axisName] = before - before != after + performScroll(element, axisName, delta) and performScroll(element, axisName, -delta) # Find the element which we should and can scroll (or document.body). findScrollableElement = (element, direction, amount, factor = 1) -> @@ -65,21 +67,13 @@ findScrollableElement = (element, direction, amount, factor = 1) -> element = element.parentElement || document.body element -performScroll = (element, axisName, amount, checkVisibility = true) -> - before = element[axisName] - element[axisName] += amount - - if checkVisibility - # 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. - # TODO(smblott) Refactor this out of here. - rect = activatedElement.getBoundingClientRect() - if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) - activatedElement = element - - # Return true if we successfully scrolled by the requested amount, false otherwise. - amount == element[axisName] - before +checkVisibility = (element) -> + # 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. + rect = activatedElement.getBoundingClientRect() + if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth) + activatedElement = element # How scrolling is handled: # - For non-smooth scrolling, the entire scroll happens immediately. @@ -109,13 +103,16 @@ doScrollBy = do -> axisName = scrollProperties[direction].axisName unless settings.get "smoothScroll" - return performScroll element, axisName, amount + # Jump scrolling. + performScroll element, axisName, amount + checkVisibility element + return if mostRecentActivationTime == time or lastEvent?.repeat # Either the most-recently activated animator has not yet received its keyup event (so it's still - # scrolling), or this is a keyboard repeat (for which we don't initiate a new animator). We need both - # of these checks because sometimes (perhaps one time in twenty) the last keyboard repeat arrives - # *after* the corresponding keyup. + # scrolling), or this is a keyboard repeat (for which we don't initiate a new animator). + # NOTE(smblott) We need both of these checks because sometimes (perhaps one time in twenty) the last + # keyboard repeat arrives *after* the corresponding keyup. return mostRecentActivationTime = activationTime = ++time @@ -131,29 +128,26 @@ doScrollBy = do -> duration = Math.max 100, 20 * Math.log amount totalDelta = 0 + totalElapsed = 0.0 calibration = 1.0 - initialTimestamp = null previousTimestamp = null animatorId = null - progressAnimation = -> + advanceAnimation = -> animatorId = requestAnimationFrame animate cancelAnimation = -> - # Make one final call of performScroll to check the visibility of the activated element. - performScroll element, axisName, 0, true cancelAnimationFrame animatorId animate = (timestamp) -> - initialTimestamp ?= timestamp previousTimestamp ?= timestamp if timestamp == previousTimestamp - return progressAnimation() + return advanceAnimation() # The elapsed time is typically about 16ms. elapsed = timestamp - previousTimestamp - totalElapsed = timestamp - initialTimestamp + totalElapsed += elapsed previousTimestamp = timestamp # The constants in the duration calculation, above, are chosen to provide reasonable scroll speeds for @@ -168,15 +162,14 @@ doScrollBy = do -> delta = Math.ceil amount * (elapsed / duration) * calibration delta = if isKeyStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta - if delta and performScroll element, axisName, sign * delta, false + if delta and performScroll element, axisName, sign * delta totalDelta += delta - progressAnimation() + advanceAnimation() else - # We're done. Make one final call of performScroll to check the visibility of the activated element. - performScroll element, axisName, 0, true + checkVisibility element cancelAnimationFrame animatorId - progressAnimation() + advanceAnimation() Scroller = init: (frontendSettings) -> -- cgit v1.2.3 From 3816ff9438a73233bdea2512f51439306a1be156 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Thu, 13 Nov 2014 21:37:04 +0000 Subject: Smooth scroll; admit count prefixes. --- content_scripts/scroller.coffee | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index c69ca235..ed024724 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -88,7 +88,6 @@ checkVisibility = (element) -> doScrollBy = do -> # This is logical time. Time is advanced each time an animator is activated, and on each keyup event. time = 0 - mostRecentActivationTime = -1 lastEvent = null keyHandler = null @@ -108,14 +107,10 @@ doScrollBy = do -> checkVisibility element return - if mostRecentActivationTime == time or lastEvent?.repeat - # Either the most-recently activated animator has not yet received its keyup event (so it's still - # scrolling), or this is a keyboard repeat (for which we don't initiate a new animator). - # NOTE(smblott) We need both of these checks because sometimes (perhaps one time in twenty) the last - # keyboard repeat arrives *after* the corresponding keyup. - return + # We don't activate new animators on keyboard repeats. + return if lastEvent?.repeat - mostRecentActivationTime = activationTime = ++time + activationTime = ++time isKeyStillDown = -> time == activationTime -- cgit v1.2.3 From 59cc099d5be454aabbb46ac3bd0ac30c7ab7c0ff Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Fri, 14 Nov 2014 05:20:57 +0000 Subject: Smooth scroll; incororate feedback from @mrmr1993. --- content_scripts/scroller.coffee | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index ed024724..a1f5bfa1 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -30,7 +30,8 @@ getDimension = (el, direction, amount) -> amount # Perform a scroll. Return true if we successfully scrolled by the requested amount, and false otherwise. -performScroll = (element, axisName, amount) -> +performScroll = (element, direction, amount) -> + axisName = scrollProperties[direction].axisName before = element[axisName] element[axisName] += amount amount == element[axisName] - before @@ -50,18 +51,16 @@ shouldScroll = (element, direction) -> # Instead, we scroll the element by 1 or -1 and see if it moved (then put it back). # Bug verified in Chrome 38.0.2125.104. isScrollPossible = (element, direction, amount, factor) -> - axisName = scrollProperties[direction].axisName # amount, here, is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls # (only gg, G, and friends), amount can be either 'max' or zero. In the former case, we're definitely - # scrolling forwards, so any positive value will do for delta. In the latter case, we're definitely - # scrolling backwards, so a delta of -1 will do. + # 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. delta = factor * getDimension(element, direction, amount) || -1 - delta = delta / Math.abs delta # 1 or -1 - performScroll(element, axisName, delta) and performScroll(element, axisName, -delta) + delta = Math.sign delta # 1 or -1 + performScroll(element, direction, delta) and performScroll(element, direction, -delta) # Find the element which we should and can scroll (or document.body). findScrollableElement = (element, direction, amount, factor = 1) -> - axisName = scrollProperties[direction].axisName while element != document.body and not (isScrollPossible(element, direction, amount, factor) and shouldScroll(element, direction)) element = element.parentElement || document.body @@ -94,16 +93,13 @@ doScrollBy = do -> (element, direction, amount) -> return unless amount - unless keyHandler - keyHandler = handlerStack.push - keydown: -> lastEvent = event - keyup: -> time += 1 - - axisName = scrollProperties[direction].axisName + keyHandler ?= handlerStack.push + keydown: -> lastEvent = event + keyup: -> time += 1 unless settings.get "smoothScroll" # Jump scrolling. - performScroll element, axisName, amount + performScroll element, direction, amount checkVisibility element return @@ -116,7 +112,7 @@ doScrollBy = do -> time == activationTime # Store amount's sign and make amount positive; the logic is clearer when amount is positive. - sign = amount / Math.abs amount + sign = Math.sign amount amount = Math.abs amount # Duration in ms. Allow a bit longer for longer scrolls. @@ -157,12 +153,12 @@ doScrollBy = do -> delta = Math.ceil amount * (elapsed / duration) * calibration delta = if isKeyStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta - if delta and performScroll element, axisName, sign * delta + if delta and performScroll element, direction, sign * delta totalDelta += delta advanceAnimation() else checkVisibility element - cancelAnimationFrame animatorId + cancelAnimation() advanceAnimation() -- cgit v1.2.3 From e12b9422580c898af381ba97fa9806bfe3264e96 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Fri, 14 Nov 2014 08:40:03 +0000 Subject: Smooth scroll; partial fix to race condition. --- content_scripts/scroller.coffee | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index a1f5bfa1..ecb739ec 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -89,13 +89,18 @@ doScrollBy = do -> time = 0 lastEvent = null keyHandler = null + keyIsDown = false (element, direction, amount) -> return unless amount keyHandler ?= handlerStack.push - keydown: -> lastEvent = event - keyup: -> time += 1 + keydown: -> + keyIsDown = true unless event.repeat + lastEvent = event + keyup: -> + keyIsDown = false + time += 1 unless settings.get "smoothScroll" # Jump scrolling. @@ -109,7 +114,7 @@ doScrollBy = do -> activationTime = ++time isKeyStillDown = -> - time == activationTime + time == activationTime and keyIsDown # Store amount's sign and make amount positive; the logic is clearer when amount is positive. sign = Math.sign amount -- cgit v1.2.3 From 9bb2b1b6db5d7276408857d9032d2a9c1b0fe9dd Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Fri, 14 Nov 2014 12:01:39 +0000 Subject: Smooth scroll; move doScrollBy to it's own object. The main reason for doing this is so that we can add the (now) CoreScroller callbacks to the handler stack earlier, and therefore track @keyIsDown correctly at startup, while also keeping local state local. --- content_scripts/scroller.coffee | 54 ++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index ecb739ec..e418a930 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -3,7 +3,6 @@ # input elements. This mechanism allows us to decide whether to scroll a div or to scroll the whole document. # activatedElement = null -settings = null scrollProperties = x: { @@ -83,38 +82,37 @@ checkVisibility = (element) -> # continues scrolling at least until its corresponding keyup event is received. We never initiate a new # animator on keyboard repeat. -# Scroll by a relative amount (a number) in some direction, possibly smoothly. -doScrollBy = do -> - # This is logical time. Time is advanced each time an animator is activated, and on each keyup event. - time = 0 - lastEvent = null - keyHandler = null - keyIsDown = false - - (element, direction, amount) -> +CoreScroller = + init: (frontendSettings) -> + @settings = frontendSettings + @time = 0 + @lastEvent = null + @keyIsDown = false + handlerStack.push + keydown: (event) => + @keyIsDown = true + @lastEvent = event + keyup: => + @keyIsDown = false + @time += 1 + + # Scroll element by a relative amount (a number) in some direction. + scroll: (element, direction, amount) -> return unless amount - keyHandler ?= handlerStack.push - keydown: -> - keyIsDown = true unless event.repeat - lastEvent = event - keyup: -> - keyIsDown = false - time += 1 - - unless settings.get "smoothScroll" + unless @settings.get "smoothScroll" # Jump scrolling. performScroll element, direction, amount checkVisibility element return # We don't activate new animators on keyboard repeats. - return if lastEvent?.repeat + return if @lastEvent?.repeat - activationTime = ++time + activationTime = ++@time - isKeyStillDown = -> - time == activationTime and keyIsDown + isMyKeyStillDown = => + @time == activationTime and @keyIsDown # Store amount's sign and make amount positive; the logic is clearer when amount is positive. sign = Math.sign amount @@ -149,14 +147,14 @@ doScrollBy = do -> # The constants in the duration calculation, above, are chosen to provide reasonable scroll speeds for # scrolls resulting from distinct keypresses. For continuous scrolls (where the key remains depressed), # some scrolls are too slow, and others too fast. Here, we compensate a bit. - if isKeyStillDown() and 50 <= totalElapsed and 0.5 <= calibration <= 1.6 + if isMyKeyStillDown() and 50 <= totalElapsed and 0.5 <= calibration <= 1.6 calibration *= 1.05 if 1.05 * calibration * amount <= 150 # Speed up slow scrolls. calibration *= 0.95 if 150 <= 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 isKeyStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta + delta = if isMyKeyStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta if delta and performScroll element, direction, sign * delta totalDelta += delta @@ -169,8 +167,8 @@ doScrollBy = do -> Scroller = init: (frontendSettings) -> - settings = 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. @@ -188,7 +186,7 @@ Scroller = element = findScrollableElement activatedElement, direction, amount, factor elementAmount = factor * getDimension element, direction, amount - doScrollBy element, direction, elementAmount + CoreScroller.scroll element, direction, elementAmount scrollTo: (direction, pos) -> return unless document.body or activatedElement @@ -196,7 +194,7 @@ Scroller = element = findScrollableElement activatedElement, direction, pos amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] - doScrollBy element, direction, amount + CoreScroller.scroll element, direction, amount root = exports ? window root.Scroller = Scroller -- cgit v1.2.3 From ce61f39716665e93f510b40580ec8dd046a84ffd Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Fri, 14 Nov 2014 13:26:49 +0000 Subject: Smooth scroll; better documentation for calibration. --- content_scripts/scroller.coffee | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index e418a930..1e5bac65 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -33,7 +33,7 @@ performScroll = (element, direction, amount) -> axisName = scrollProperties[direction].axisName before = element[axisName] element[axisName] += amount - amount == element[axisName] - before + element[axisName] == amount + before # Test whether element should be scrolled. shouldScroll = (element, direction) -> @@ -88,6 +88,7 @@ CoreScroller = @time = 0 @lastEvent = null @keyIsDown = false + handlerStack.push keydown: (event) => @keyIsDown = true @@ -96,6 +97,14 @@ CoreScroller = @keyIsDown = false @time += 1 + # 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 => more slow down. + @maxCalibration = 1.6 # Controls how much we're willing to speed scrolls up; bigger => more speed up. + @calibrationBoundary = 150 # Boundary between scrolls which are considered too slow, and those too fast. + # Scroll element by a relative amount (a number) in some direction. scroll: (element, direction, amount) -> return unless amount @@ -110,9 +119,7 @@ CoreScroller = return if @lastEvent?.repeat activationTime = ++@time - - isMyKeyStillDown = => - @time == activationTime and @keyIsDown + isMyKeyStillDown = => @time == activationTime and @keyIsDown # Store amount's sign and make amount positive; the logic is clearer when amount is positive. sign = Math.sign amount @@ -127,17 +134,12 @@ CoreScroller = previousTimestamp = null animatorId = null - advanceAnimation = -> - animatorId = requestAnimationFrame animate + advanceAnimation = -> animatorId = requestAnimationFrame animate + cancelAnimation = -> cancelAnimationFrame animatorId - cancelAnimation = -> - cancelAnimationFrame animatorId - - animate = (timestamp) -> + animate = (timestamp) => previousTimestamp ?= timestamp - - if timestamp == previousTimestamp - return advanceAnimation() + return advanceAnimation() if timestamp == previousTimestamp # The elapsed time is typically about 16ms. elapsed = timestamp - previousTimestamp @@ -145,11 +147,11 @@ CoreScroller = previousTimestamp = timestamp # The constants in the duration calculation, above, are chosen to provide reasonable scroll speeds for - # scrolls resulting from distinct keypresses. For continuous scrolls (where the key remains depressed), - # some scrolls are too slow, and others too fast. Here, we compensate a bit. - if isMyKeyStillDown() and 50 <= totalElapsed and 0.5 <= calibration <= 1.6 - calibration *= 1.05 if 1.05 * calibration * amount <= 150 # Speed up slow scrolls. - calibration *= 0.95 if 150 <= 0.95 * calibration * amount # Slow down fast scrolls. + # 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 isMyKeyStillDown() and 50 <= 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. -- cgit v1.2.3 From fbd487a7387f4ef568313d818cb88946d130d9b2 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Fri, 14 Nov 2014 15:06:50 +0000 Subject: Smooth scroller; move calibration constants out of init. --- content_scripts/scroller.coffee | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 1e5bac65..ab9be22c 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -97,13 +97,13 @@ CoreScroller = @keyIsDown = false @time += 1 - # 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 => more slow down. - @maxCalibration = 1.6 # Controls how much we're willing to speed scrolls up; bigger => more speed up. - @calibrationBoundary = 150 # Boundary between scrolls which are considered too slow, and those too fast. + # 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 => more slow down. + maxCalibration: 1.6 # Controls how much we're willing to speed scrolls up; bigger => more speed up. + calibrationBoundary: 150 # Boundary between scrolls which are considered too slow, and those too fast. # Scroll element by a relative amount (a number) in some direction. scroll: (element, direction, amount) -> @@ -150,8 +150,8 @@ CoreScroller = # 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 isMyKeyStillDown() and 50 <= 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. + 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. -- cgit v1.2.3 From ed3d40acb350812b7e0dbafd30f2777f04369d86 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Sat, 15 Nov 2014 08:19:04 +0000 Subject: Smooth scroll; optimization and more tidy up. --- content_scripts/scroller.coffee | 82 +++++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index ab9be22c..27744116 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -38,49 +38,49 @@ performScroll = (element, direction, amount) -> # Test whether element should be scrolled. shouldScroll = (element, direction) -> computedStyle = window.getComputedStyle(element) - # Elements with `overflow: hidden` should not be scrolled. + # Elements with `overflow: hidden` must not be scrolled. return false if computedStyle.getPropertyValue("overflow-#{direction}") == "hidden" - # Non-visible elements should not be scrolled. + # 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 actually scrolls in the direction required when asked to do so. Due to chrome bug +# 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). # Bug verified in Chrome 38.0.2125.104. -isScrollPossible = (element, direction, amount, factor) -> - # amount, here, is treated as a relative amount, which is correct for relative scrolls. For absolute scrolls - # (only gg, G, and friends), amount can be either 'max' 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. +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 'max' 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) -# Find the element which we should and can scroll (or document.body). -findScrollableElement = (element, direction, amount, factor = 1) -> +# 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 (isScrollPossible(element, direction, amount, factor) and shouldScroll(element, direction)) + 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, 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. + # 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 -# How scrolling is handled: -# - For non-smooth scrolling, the entire scroll happens immediately. +# 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 corresponding keyup event is received. We never initiate a new -# animator on keyboard repeat. +# continues scrolling at least until its keyup event is received. We never initiate a new animator on +# keyboard repeat. CoreScroller = init: (frontendSettings) -> @@ -97,13 +97,16 @@ CoreScroller = @keyIsDown = false @time += 1 + # Return true if CoreScroller would not initiate a new scroll right now. + wouldNotInitiateScroll: -> @lastEvent?.repeat and @settings.get "smoothScroll" + # 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 => more slow down. - maxCalibration: 1.6 # Controls how much we're willing to speed scrolls up; bigger => more speed up. - calibrationBoundary: 150 # Boundary between scrolls which are considered too slow, and those too fast. + 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. # Scroll element by a relative amount (a number) in some direction. scroll: (element, direction, amount) -> @@ -115,31 +118,28 @@ CoreScroller = checkVisibility element return - # We don't activate new animators on keyboard repeats. + # We don't activate new animators on keyboard repeats; rather, the most-recently activated animator + # continues scrolling. return if @lastEvent?.repeat activationTime = ++@time - isMyKeyStillDown = => @time == activationTime and @keyIsDown + myKeyIsStillDown = => @time == activationTime and @keyIsDown - # Store amount's sign and make amount positive; the logic is clearer when amount is positive. + # Store amount's sign and make amount positive; the arithmetic is clearer when amount is positive. sign = Math.sign amount amount = Math.abs amount - # Duration in ms. Allow a bit longer for longer scrolls. + # 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 - animatorId = null - - advanceAnimation = -> animatorId = requestAnimationFrame animate - cancelAnimation = -> cancelAnimationFrame animatorId animate = (timestamp) => previousTimestamp ?= timestamp - return advanceAnimation() if timestamp == previousTimestamp + return requestAnimationFrame(animate) if timestamp == previousTimestamp # The elapsed time is typically about 16ms. elapsed = timestamp - previousTimestamp @@ -149,23 +149,24 @@ CoreScroller = # 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 isMyKeyStillDown() and 50 <= totalElapsed and @minCalibration <= calibration <= @maxCalibration + 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 isMyKeyStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta + delta = if myKeyIsStillDown() then delta else Math.max 0, Math.min delta, amount - totalDelta if delta and performScroll element, direction, sign * delta totalDelta += delta - advanceAnimation() + requestAnimationFrame animate else + # We're done. checkVisibility element - cancelAnimation() - advanceAnimation() + # Launch animator. + requestAnimationFrame animate Scroller = init: (frontendSettings) -> @@ -186,15 +187,18 @@ Scroller = activatedElement ||= document.body return unless activatedElement - element = findScrollableElement activatedElement, direction, amount, factor - elementAmount = factor * getDimension element, direction, amount - CoreScroller.scroll element, direction, elementAmount + # 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 + element = findScrollableElement activatedElement, direction, pos, 1 amount = getDimension(element,direction,pos) - element[scrollProperties[direction].axisName] CoreScroller.scroll element, direction, amount -- cgit v1.2.3 From da6c5387ff89ad523f38881248f947525d0a4535 Mon Sep 17 00:00:00 2001 From: Stephen Blott Date: Sun, 16 Nov 2014 08:47:15 +0000 Subject: Smooth scroll; fix-ups requested by @philc. --- background_scripts/settings.coffee | 2 +- content_scripts/scroller.coffee | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index f68a51d7..4a63a5fb 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -61,7 +61,7 @@ root.Settings = Settings = # or strings defaults: scrollStepSize: 60 - smoothScroll: false + smoothScroll: true keyMappings: "# Insert your prefered key mappings here." linkHintCharacters: "sadfjklewcmpgh" linkHintNumbers: "0123456789" diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee index 27744116..0964a289 100644 --- a/content_scripts/scroller.coffee +++ b/content_scripts/scroller.coffee @@ -16,12 +16,18 @@ scrollProperties = viewSize: 'clientWidth' } +# 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]] @@ -35,7 +41,7 @@ performScroll = (element, direction, amount) -> element[axisName] += amount element[axisName] == amount + before -# Test whether element should be scrolled. +# 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. @@ -47,13 +53,15 @@ shouldScroll = (element, direction) -> # 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). +# 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 'max' 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. + # 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) @@ -82,6 +90,8 @@ checkVisibility = (element) -> # continues scrolling at least until its keyup event is received. We never initiate a new animator on # keyboard repeat. +# 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 @@ -168,6 +178,7 @@ CoreScroller = # 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 -- cgit v1.2.3