Created
March 14, 2023 18:06
-
-
Save towerofnix/2b150f957cd2fd4b9514af63b43daaf2 to your computer and use it in GitHub Desktop.
Definitely WIP
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* This runs after a web page loads */ | |
const smoothScrollConfiguration = { | |
yVelocityFriction: 0.6, | |
yVelocityCap: Infinity, | |
yAccelerationFriction: 0.75, | |
yAccelerationCap: 8, | |
yJolt: 1.4, | |
yDisintegrateEdgePush: 0.35, | |
yDisintegrateEdgePull: 0.5, | |
}; | |
(function smoothScrollModule({ | |
yVelocityFriction = 0.6, | |
yVelocityCap = 30, | |
yAccelerationFriction = 0.75, | |
yAccelerationCap = 8, | |
yJolt = 1.4, | |
yDisintegrateEdgePull = 0.35, | |
yDisintegrateEdgePush = 0.5, | |
xVelocityFriction = 0.6, | |
xVelocityCap = 10, | |
xAccelerationFriction = 0.55, | |
xAccelerationCap = 8, | |
xJolt = 1.8, | |
scrollPastTopEdge = true, | |
scrollPastBottomEdge = true, | |
scrollPastLeftEdge = false, | |
scrollPastRightEdge = false, | |
xDisintegrationEdgePull = 0.4, | |
xDisintegrationEdgePush = 0.55, | |
matchRules: { | |
locationRules, | |
disabledElements, | |
overrideEdgeScrollingElements, | |
} = { | |
locationRules: [ | |
{ | |
"location": "^https://docs.google.com/spreadsheets/.*", | |
disableLocation: true, | |
}, | |
{ | |
"location": "^https://reddit.com/" | |
}, | |
], | |
disabledElements: [ | |
"input", | |
"label", | |
"summary", | |
"textarea", | |
], | |
overrideEdgeScrollingElements: [ | |
"aside", | |
], | |
}, | |
}) { | |
let disabledForCurrentLocation = false; | |
let overrideEdgeScrolling = false; | |
let againstTopEdge = false; | |
let againstBottomEdge = false; | |
let againstLeftEdge = false; | |
let againstRightEdge = false; | |
let xVelocity = 0; | |
let xAcceleration = 0; | |
let yVelocity = 0; | |
let yAcceleration = 0; | |
let keys = {}; | |
function keyDown(evt, key) { | |
if ( | |
key === "up" || key === "down" || | |
key === "left" || key === "right" | |
) { | |
evt.preventDefault(); | |
} | |
if (key === "up") { | |
keys.up = true; | |
keys.down = false; | |
if (yVelocity > 0) { | |
yVelocity = 0; | |
yAcceleration = 0; | |
} | |
} | |
if (key === "down") { | |
keys.down = true; | |
keys.up = false; | |
if (yVelocity < 0) { | |
yVelocity = 0; | |
yAcceleration = 0; | |
} | |
} | |
if (key === "left") { | |
keys.left = true; | |
keys.right = false; | |
if (xVelocity > 0) { | |
xVelocity = 0; | |
xAcceleration = 0; | |
} | |
} | |
if (key === "right") { | |
keys.right = true; | |
keys.left = false; | |
if (xVelocity < 0) { | |
xVelocity = 0; | |
xAcceleration = 0; | |
} | |
} | |
queueMathLoop(); | |
} | |
function keyUp(evt, key) { | |
if (key === "up") { | |
keys.up = false; | |
} | |
if (key === "down") { | |
keys.down = false; | |
} | |
if (key === "left") { | |
keys.left = false; | |
} | |
if (key === "right") { | |
keys.right = false; | |
} | |
} | |
function disintegrate(amount, num) { | |
return 1 - amount * (1 - num); | |
} | |
function resetScrollState() { | |
xVelocity = 0; | |
xAcceleration = 0; | |
yVelocity = 0; | |
yAcceleration = 0; | |
keys = {}; | |
queueMathLoop(); | |
} | |
let mathLoopQueued = false; | |
function queueMathLoop() { | |
if (mathLoopQueued) return; | |
mathLoopQueued = true; | |
setTimeout(mathLoop, 20); | |
} | |
function mathLoop() { | |
if (keys.left) { | |
if (againstLeftEdge) { | |
xAcceleration -= 0.8 * xJolt; | |
} else { | |
xAcceleration -= xJolt; | |
} | |
} | |
if (keys.right) { | |
if (againstRightEdge) { | |
xAcceleration += 0.8 * xJolt; | |
} else { | |
xAcceleration += xJolt; | |
} | |
} | |
if (keys.up) { | |
if (againstTopEdge) { | |
yAcceleration -= 0.8 * yJolt; | |
} else { | |
yAcceleration -= yJolt; | |
} | |
} | |
if (keys.down) { | |
if (againstBottomEdge) { | |
yAcceleration += 0.8 * yJolt; | |
} else { | |
yAcceleration += yJolt; | |
} | |
} | |
xVelocity += xAcceleration; | |
yVelocity += yAcceleration; | |
if (againstLeftEdge) { | |
if (keys.left) { | |
xVelocity *= disintegrate( | |
xDisintegrationEdgePush, | |
xVelocityFriction); | |
} else { | |
xVelocity *= disintegrate( | |
xDisintegrationEdgePull, | |
xVelocityFriction); | |
} | |
} else if (againstRightEdge) { | |
if (keys.right) { | |
xVelocity *= disintegrate( | |
xDisintegrationEdgePush, | |
xVelocityFriction); | |
} else { | |
xVelocity *= disintegrate( | |
xDisintegrationEdgePull, | |
xVelocityFriction); | |
} | |
} else { | |
xVelocity *= xVelocityFriction; | |
} | |
if (againstTopEdge) { | |
if (keys.up) { | |
yVelocity *= disintegrate( | |
yDisintegrateEdgePush, | |
yVelocityFriction); | |
} else { | |
yVelocity *= disintegrate( | |
yDisintegrateEdgePull, | |
yVelocityFriction); | |
} | |
} else if (againstBottomEdge) { | |
if (keys.down) { | |
yVelocity *= disintegrate( | |
yDisintegrateEdgePush, | |
yVelocityFriction); | |
} else { | |
yVelocity *= disintegrate( | |
yDisintegrateEdgePull, | |
yVelocityFriction); | |
} | |
} else { | |
yVelocity *= yVelocityFriction; | |
} | |
xAcceleration *= xAccelerationFriction; | |
yAcceleration *= yAccelerationFriction; | |
if (Math.abs(yVelocity) < 0.25) yVelocity = 0; | |
if (Math.abs(xVelocity) < 0.25) xVelocity = 0; | |
if (Math.abs(yAcceleration) < 0.25) yAcceleration = 0; | |
if (Math.abs(yAcceleration) < 0.25) yAcceleration = 0; | |
if (Math.abs(xVelocity) > xVelocityCap) | |
xVelocity = | |
Math.sign(xVelocity) * xVelocityCap; | |
if (Math.abs(yVelocity) > yVelocityCap) | |
yVelocity = | |
Math.sign(yVelocity) * yVelocityCap; | |
if (Math.abs(xAcceleration) > xAccelerationCap) | |
xAcceleration = | |
Math.sign(xAcceleration) * xAccelerationCap; | |
if (Math.abs(yAcceleration) > yAccelerationCap) | |
yAcceleration = | |
Math.sign(yAcceleration) * yAccelerationCap; | |
if (xVelocity || yVelocity) { | |
setTimeout(mathLoop, 20); | |
} else { | |
mathLoopQueued = false; | |
} | |
} | |
const edgeOffsetMode = 'translate'; | |
function showEdgeOffset(scrollElement, xOffset, yOffset) { | |
let style = {}; | |
xOffset = Math.round(xOffset * devicePixelRatio) / devicePixelRatio; | |
yOffset = Math.round(yOffset * devicePixelRatio) / devicePixelRatio; | |
// Nice math that doesn't work: | |
/* | |
switch (edgeOffsetMode) { | |
// Mostly reliable but can blank the screen depending on content. | |
// Buggy for right detection, but fine for bottom detection. | |
case 'translate': { | |
style = { | |
transform: `translate(${xOffset}px, ${yOffset}px)`, | |
}; | |
break; | |
} | |
// Good for top, left, and right detection, but flat-out doesn't work | |
// for bottom detection. | |
case 'position-padding': { | |
style = { | |
position: 'absolute', | |
top: yOffset > 0 ? yOffset + 'px' : '0px', | |
left: xOffset + 'px', | |
paddingRight: xOffset < 0 ? -xOffset + 'px' : '0px', | |
// When the body is position: absolute, its bottom margin apparently | |
// doesn't count towards the page height. Even if we don't display | |
// past the bottom edge we still need to provide this padding so that | |
// we aren't pulled up as though the bottom margin weren't there when | |
// displaying past the horizontal edges. | |
paddingBottom: '8px', | |
}; | |
break; | |
} | |
} | |
*/ | |
// Ugly math that does work (hopefully): | |
// This works for all edges and corners(!) except bottom-right. | |
style = { | |
position: '', | |
transform: '', | |
top: '', | |
left: '', | |
paddingRight: '', | |
paddingBottom: '', | |
} | |
// If clause for top, left, and right edges. | |
if (yOffset >= 0) { | |
Object.assign(style, { | |
position: 'absolute', | |
top: yOffset + 'px', | |
left: xOffset + 'px', | |
paddingRight: xOffset < 0 ? -xOffset + 'px' : '0px', | |
// When the body is position: absolute, its bottom margin apparently | |
// doesn't count towards the page height. Even if we don't display | |
// past the bottom edge we still need to provide this padding so that | |
// we aren't pulled up as though the bottom margin weren't there when | |
// displaying past the horizontal edges. | |
paddingBottom: '8px', | |
}); | |
} else { | |
// Else clause for bottom edge. This is OK for the bottom left corner | |
// but not bottom right, which is disabled with Math.max(x, 0) here. | |
const translateX = Math.max(xOffset, 0); | |
const translateY = yOffset; | |
style.transform = `translate(${translateX}px, ${translateY}px)`; | |
} | |
if (xOffset === 0 && yOffset === 0) { | |
for (const key of Object.keys(style)) { | |
scrollElement.style[key] = ""; | |
} | |
} else { | |
Object.assign(scrollElement.style, style); | |
} | |
} | |
function getScrollElement() { | |
let scrollElement = document.activeElement ?? document.documentElement; | |
while ( | |
scrollElement !== document.documentElement && | |
scrollElement.scrollHeight <= scrollElement.clientHeight && | |
scrollElement.scrollWidth <= scrollElement.clientWidth | |
) { | |
scrollElement = scrollElement.parentElement; | |
} | |
return scrollElement; | |
} | |
function updateOverrideEdgeScrolling() { | |
const scrollElement = getScrollElement(); | |
overrideEdgeScrolling = scrollElement.matches(overrideEdgeScrollingElements); | |
} | |
function animationLoop() { | |
updateOverrideEdgeScrolling(); | |
const scrollElement = getScrollElement(); | |
const yPreferredScroll = scrollElement.scrollTop + yVelocity; | |
const xPreferredScroll = scrollElement.scrollLeft + xVelocity; | |
let xEdgeOffset = 0; | |
let yEdgeOffset = 0; | |
// Reset displayed edge offset. This won't actually be rendered to the screen | |
// (we call this again with correct values during the same frame later in this | |
// function), it's just to make the following math correctly read the bounds | |
// of the scrolling area. | |
showEdgeOffset(scrollElement, 0, 0); | |
const xScrollLimit = ( | |
Math.max( | |
scrollElement.clientWidth, | |
scrollElement.scrollWidth, | |
scrollElement.offsetWidth) | |
- scrollElement.clientWidth); | |
const yScrollLimit = ( | |
Math.max( | |
scrollElement.clientHeight, | |
scrollElement.scrollHeight, | |
scrollElement.offsetHeight) | |
- scrollElement.clientHeight); | |
againstLeftEdge = (xPreferredScroll < 0); | |
againstTopEdge = (yPreferredScroll < 0); | |
againstRightEdge = (xPreferredScroll > xScrollLimit); | |
againstBottomEdge = (yPreferredScroll > yScrollLimit); | |
if (againstLeftEdge) { | |
xEdgeOffset = -2 * xPreferredScroll; | |
} | |
if (againstTopEdge) { | |
yEdgeOffset = -2 * yPreferredScroll; | |
} | |
if (againstRightEdge) { | |
xEdgeOffset = -2 * (xPreferredScroll - xScrollLimit); | |
} | |
if (againstBottomEdge) { | |
yEdgeOffset = -2 * (yPreferredScroll - yScrollLimit); | |
} | |
if ( | |
againstLeftEdge && (!scrollPastLeftEdge || overrideEdgeScrolling) || | |
againstRightEdge && (!scrollPastRightEdge || overrideEdgeScrolling) | |
) { | |
xAcceleration = 0; | |
xVelocity = 0; | |
xEdgeOffset = 0; | |
againstLeftEdge = false; | |
againstRightEdge = false; | |
} | |
if ( | |
againstTopEdge && (!scrollPastTopEdge || overrideEdgeScrolling) || | |
againstBottomEdge && (!scrollPastBottomEdge || overrideEdgeScrolling) | |
) { | |
yAcceleration = 0; | |
yVelocity = 0; | |
yEdgeOffset = 0; | |
againstTopEdge = false; | |
againstBottomEdge = false; | |
} | |
if ( | |
!againstLeftEdge && !againstRightEdge && | |
!againstTopEdge && !againstBottomEdge | |
) { | |
showEdgeOffset(scrollElement, 0, 0); | |
} | |
scrollElement.scrollTo({ | |
left: xPreferredScroll, | |
top: yPreferredScroll, | |
behavior: 'instant', | |
}); | |
showEdgeOffset(scrollElement, xEdgeOffset, yEdgeOffset); | |
requestAnimationFrame(animationLoop); | |
} | |
queueMathLoop(); | |
requestAnimationFrame(animationLoop); | |
function cancelSteal(evt) { | |
if (disabledForCurrentLocation) { | |
return true; | |
} | |
if (evt.metaKey || evt.shiftKey || evt.altKey || evt.ctrlKey) { | |
return true; | |
} | |
if (document.activeElement?.matches(disabledElements)) { | |
return true; | |
} | |
return false; | |
} | |
const disabledLocationRegexes = locationRules | |
.filter(rule => rule.disableLocation) | |
.map(rule => new RegExp(rule.location)); | |
function updateLocationDisabled() { | |
const locationString = location.href; | |
disabledForCurrentLocation = false; | |
for (const regex of disabledLocationRegexes) { | |
if (regex.test(locationString)) { | |
disabledForCurrentLocation = true; | |
break; | |
} | |
} | |
if (disabledForCurrentLocation) { | |
resetScrollState(); | |
} | |
} | |
updateLocationDisabled(); | |
window.addEventListener("popstate", updateLocationDisabled); | |
window.addEventListener("hashchange", updateLocationDisabled); | |
window.addEventListener("keydown", (evt) => { | |
if (cancelSteal(evt)) return; | |
switch (evt.key) { | |
case "ArrowDown": keyDown(evt, "down"); break; | |
case "ArrowUp": keyDown(evt, "up"); break; | |
case "ArrowLeft": keyDown(evt, "left"); break; | |
case "ArrowRight": keyDown(evt, "right"); break; | |
} | |
}); | |
window.addEventListener("keyup", (evt) => { | |
if (cancelSteal(evt)) return; | |
switch (evt.key) { | |
case "ArrowDown": keyUp(evt, "down"); break; | |
case "ArrowUp": keyUp(evt, "up"); break; | |
case "ArrowLeft": keyUp(evt, "left"); break; | |
case "ArrowRight": keyUp(evt, "right"); break; | |
} | |
}); | |
})(smoothScrollConfiguration); | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment