Skip to content

Instantly share code, notes, and snippets.

@towerofnix
Created March 14, 2023 18:06
Show Gist options
  • Save towerofnix/2b150f957cd2fd4b9514af63b43daaf2 to your computer and use it in GitHub Desktop.
Save towerofnix/2b150f957cd2fd4b9514af63b43daaf2 to your computer and use it in GitHub Desktop.
Definitely WIP
/* 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