Greasy Fork is available in English.
Limit visible posts and comments on Reddit and reveal more on demand.
// ==UserScript==
// @name Reddit Scroll Brake
// @namespace https://github.com/atharvj/reddit-scroll-brake
// @version 1.0.4
// @description Limit visible posts and comments on Reddit and reveal more on demand.
// @license MIT
// @match https://*.reddit.com/*
// @match https://www.reddit.com/*
// @match https://reddit.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
"use strict";
const INITIAL_VISIBLE_ITEMS = 10;
const ITEMS_PER_CLICK = 5;
const BRAKE_ID = "reddit-scroll-brake";
const STYLE_ID = "reddit-scroll-brake-styles";
const STORAGE_KEY = "reddit-scroll-brake-items-per-click";
const SCAN_DELAY_MS = 75;
const ROUTE_SETTLE_DELAY_MS = 300;
const BRAKE_PANEL_HEIGHT = 86;
const TOP_RESET_SCROLL_Y = 120;
const TOP_RESET_MIN_DISTANCE = 600;
const MIN_ITEMS_PER_CLICK = 1;
const MAX_ITEMS_PER_CLICK = 100;
const MIN_POST_HEIGHT = 48;
const MIN_COMMENT_HEIGHT = 24;
const EXPAND_LOCK_GRACE_MS = 1200;
const MAX_SAVED_ROUTE_STATES = 12;
let visibleItemLimit = getInitialVisibleItemLimit();
let itemsPerClick = readSavedItemsPerClick();
let currentRouteKey = getRouteKey();
let scanTimer = null;
let lastTouchY = null;
let lastScrollY = window.scrollY;
let routeSettlesAt = 0;
let isLocked = false;
let maxScrollY = null;
let minScrollYForNextLock = null;
let allowMaxScrollIncreaseUntil = 0;
let currentRecordCount = 0;
const orderedKeys = [];
const routeStates = new Map();
function addStyles() {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${BRAKE_ID} {
position: fixed;
left: 50%;
bottom: 18px;
z-index: 2147483647;
box-sizing: border-box;
width: min(520px, calc(100vw - 32px));
transform: translateX(-50%);
padding: 8px 10px;
border: 1px solid var(--color-neutral-border-weak, var(--color-tone-5, #edeff1));
border-radius: var(--radius-md, 20px);
background: var(--color-neutral-background-strong, var(--color-tone-7, #ffffff));
color: var(--color-neutral-content-strong, var(--color-tone-1, #0f1a1c));
box-shadow: var(--boxshadow-modal, 0 12px 36px rgba(15, 26, 28, 0.22));
}
#${BRAKE_ID}[hidden] {
display: none !important;
}
#${BRAKE_ID} .reddit-scroll-brake-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
font: 15px/1.3 var(--font-sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
}
#${BRAKE_ID} button {
border: 0;
border-radius: 999px;
padding: 8px 14px;
background: var(--color-brand-background, var(--color-global-brand-orangered, #ff4500));
color: white;
font: 700 15px/1.2 var(--font-sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
cursor: pointer;
}
#${BRAKE_ID} button:hover {
background: var(--color-brand-background-hover, #d93a00);
}
#${BRAKE_ID} button:focus-visible,
#${BRAKE_ID} input:focus-visible {
outline: 3px solid var(--color-global-focus, rgba(71, 176, 219, 0.7));
outline-offset: 3px;
}
#${BRAKE_ID} label {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
}
#${BRAKE_ID} input {
box-sizing: border-box;
width: 64px;
border: 1px solid var(--color-neutral-border, var(--color-tone-5, #edeff1));
border-radius: var(--radius-sm, 8px);
padding: 6px 8px;
background: var(--color-neutral-background, var(--color-tone-7, #ffffff));
color: inherit;
font: 600 15px/1 var(--font-sans, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
}
#${BRAKE_ID} .reddit-scroll-brake-status {
width: 100%;
text-align: center;
color: var(--color-neutral-content-weak, var(--color-tone-2, #576f76));
font-size: 12px;
}
@media (prefers-color-scheme: dark) {
#${BRAKE_ID} {
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5);
}
}
`;
document.head.appendChild(style);
}
function clampWholeNumber(value, fallback, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.floor(number)));
}
function getInitialVisibleItemLimit() {
return clampWholeNumber(INITIAL_VISIBLE_ITEMS, 10, 1, Number.MAX_SAFE_INTEGER);
}
function getRouteKey() {
return `${location.pathname}${location.search}`;
}
function readSavedItemsPerClick() {
let savedValue = null;
try {
savedValue = window.localStorage.getItem(STORAGE_KEY);
} catch (error) {
savedValue = null;
}
return clampWholeNumber(
savedValue || ITEMS_PER_CLICK,
ITEMS_PER_CLICK,
MIN_ITEMS_PER_CLICK,
MAX_ITEMS_PER_CLICK
);
}
function saveItemsPerClick() {
try {
window.localStorage.setItem(STORAGE_KEY, String(itemsPerClick));
} catch (error) {
// Local storage may be unavailable.
}
}
function isCommentsPage() {
return /\/comments\//.test(location.pathname);
}
function getMainContent() {
return (
document.querySelector("main") ||
document.querySelector('[role="main"]') ||
document.querySelector("shreddit-app") ||
document.body
);
}
function getAttributeValue(element, names) {
if (!(element instanceof Element)) {
return "";
}
for (const name of names) {
const value = element.getAttribute(name);
if (value) {
return value;
}
}
return "";
}
function normalizeThingKey(value, prefix) {
if (!value) {
return "";
}
const normalized = String(value).trim();
const thingMatch = normalized.match(new RegExp(`${prefix}_([a-z0-9]+)`, "i"));
if (thingMatch) {
return `${prefix}_${thingMatch[1]}`;
}
return normalized;
}
function getPostKeyFromHref(href) {
if (!href) {
return "";
}
try {
const url = new URL(href, location.origin);
const postMatch = url.pathname.match(/^\/r\/[^/]+\/comments\/([^/]+)/i);
if (postMatch) {
return `post:${postMatch[1]}`;
}
return "";
} catch (error) {
return "";
}
}
function getCommentKeyFromHref(href) {
if (!href) {
return "";
}
try {
const url = new URL(href, location.origin);
const commentMatch = url.pathname.match(/^\/r\/[^/]+\/comments\/[^/]+\/[^/]+\/([^/?#]+)/i);
if (commentMatch) {
return `comment:${commentMatch[1]}`;
}
if (url.hash) {
return `comment:${url.hash.replace(/^#/, "")}`;
}
return "";
} catch (error) {
return "";
}
}
function getVisibleCountableRecords(records, minHeight) {
return records.filter((record) => isCountableRecord(record.item, minHeight));
}
function isCountableRecord(element, minHeight) {
if (!(element instanceof Element) || element.closest(`#${BRAKE_ID}`)) {
return false;
}
if (
element.hidden ||
element.getAttribute("aria-hidden") === "true" ||
element.closest(
'aside, nav, header, footer, dialog, [aria-modal="true"], [role="complementary"]'
)
) {
return false;
}
const rect = element.getBoundingClientRect();
if (rect.width < 180 || rect.height < minHeight) {
return false;
}
const style = window.getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden";
}
function getDistinctCommentLinks(element) {
const keys = new Set();
element.querySelectorAll('a[href*="/comments/"]').forEach((link) => {
const key = getPostKeyFromHref(link.getAttribute("href"));
if (key) {
keys.add(key);
}
});
return keys;
}
function findPostContainerFromLink(link) {
const key = getPostKeyFromHref(link.getAttribute("href"));
if (!key) {
return link;
}
let current = link.parentElement;
let best = null;
while (current && current !== document.body) {
if (current.matches("main, [role='main'], shreddit-app")) {
break;
}
const rect = current.getBoundingClientRect();
if (rect.width >= 180 && rect.height >= MIN_POST_HEIGHT) {
const linkedPostKeys = getDistinctCommentLinks(current);
if (linkedPostKeys.size === 1 && linkedPostKeys.has(key)) {
best = current;
} else if (linkedPostKeys.size > 1) {
break;
}
}
current = current.parentElement;
}
return best || link;
}
function getPostRecords() {
const root = getMainContent();
if (!root) {
return [];
}
const selectors = [
"shreddit-post",
'[data-fullname^="t3_"]',
'[id^="t3_"]',
'div[data-testid="post-container"]',
'div[data-testid="post-content"]',
'article[data-testid="post-container"]',
'article[data-testid="post-content"]',
'article[role="article"]',
'.thing.link',
'a[id^="post-title-"][href*="/comments/"]',
'a[data-click-id="body"][href*="/comments/"]',
'a[slot="title"][href*="/comments/"]',
];
const seenItems = new Set();
const seenKeys = new Set();
const records = [];
root.querySelectorAll(selectors.join(",")).forEach((element) => {
const post = getPostElement(element);
if (
!post ||
seenItems.has(post) ||
!isCountableRecord(post, MIN_POST_HEIGHT)
) {
return;
}
seenItems.add(post);
const key = getPostKey(post);
if (!key || seenKeys.has(key)) {
return;
}
seenKeys.add(key);
records.push({ item: post, key });
});
return getVisibleCountableRecords(records, MIN_POST_HEIGHT);
}
function getPostElement(element) {
if (!(element instanceof Element)) {
return null;
}
return (
element.closest("shreddit-post") ||
element.closest('[data-testid="post-container"]') ||
element.closest('article[data-testid="post-container"]') ||
element.closest('article[data-testid="post-content"]') ||
element.closest('article[role="article"]') ||
element.closest(".thing.link") ||
element.closest('[data-fullname^="t3_"]') ||
element.closest('[id^="t3_"]') ||
(element.matches('a[href*="/comments/"]') ? findPostContainerFromLink(element) : null)
);
}
function getCommentRecords() {
const root = getMainContent();
if (!root) {
return [];
}
const selectors = [
"shreddit-comment",
'[data-fullname^="t1_"]',
'[id^="t1_"]',
'div[data-testid="comment"]',
'div[data-test-id="comment"]',
'.thing.comment',
];
const seenItems = new Set();
const seenKeys = new Set();
const records = [];
root.querySelectorAll(selectors.join(",")).forEach((element) => {
const comment = getCommentElement(element);
if (
!comment ||
seenItems.has(comment) ||
!isCountableRecord(comment, MIN_COMMENT_HEIGHT)
) {
return;
}
seenItems.add(comment);
const key = getCommentKey(comment);
if (!key || seenKeys.has(key)) {
return;
}
seenKeys.add(key);
records.push({ item: comment, key });
});
return getVisibleCountableRecords(records, MIN_COMMENT_HEIGHT);
}
function getCommentElement(element) {
if (!(element instanceof Element)) {
return null;
}
return (
element.closest("shreddit-comment") ||
element.closest('div[data-testid="comment"]') ||
element.closest('div[data-test-id="comment"]') ||
element.closest(".thing.comment") ||
element.closest('[data-fullname^="t1_"]') ||
element.closest('[id^="t1_"]') ||
null
);
}
function getPostKey(post) {
const postId = getAttributeValue(post, [
"post-id",
"postid",
"data-post-id",
]);
if (postId) {
return `post:${normalizeThingKey(postId, "t3").replace(/^t3_/, "")}`;
}
const thingId = getAttributeValue(post, [
"thingid",
"thing-id",
"data-fullname",
"data-thing-id",
]);
if (thingId) {
return `post:${normalizeThingKey(thingId, "t3").replace(/^t3_/, "")}`;
}
const elementId = normalizeThingKey(post.id, "t3");
if (/^t3_/i.test(elementId)) {
return `post:${elementId.replace(/^t3_/i, "")}`;
}
const permalink = getAttributeValue(post, [
"permalink",
"data-permalink",
"href",
]);
if (permalink) {
return getPostKeyFromHref(permalink);
}
const link = Array.from(
post.querySelectorAll('a[href*="/comments/"]')
).find((candidate) => getPostKeyFromHref(candidate.getAttribute("href")));
if (link) {
return getPostKeyFromHref(link.getAttribute("href"));
}
return "";
}
function getCommentKey(comment) {
const commentId = getAttributeValue(comment, [
"comment-id",
"commentid",
"data-comment-id",
]);
if (commentId) {
return `comment:${normalizeThingKey(commentId, "t1").replace(/^t1_/, "")}`;
}
const thingId = getAttributeValue(comment, [
"thingid",
"thing-id",
"data-fullname",
"data-thing-id",
]);
if (thingId) {
return `comment:${normalizeThingKey(thingId, "t1").replace(/^t1_/, "")}`;
}
const elementId = normalizeThingKey(comment.id, "t1");
if (/^t1_/i.test(elementId)) {
return `comment:${elementId.replace(/^t1_/i, "")}`;
}
const permalink = getAttributeValue(comment, [
"permalink",
"data-permalink",
"href",
]);
if (permalink) {
return getCommentKeyFromHref(permalink);
}
const timestampLink = Array.from(
comment.querySelectorAll('a[data-click-id="timestamp"], a[href*="/comments/"]')
).find((candidate) =>
getCommentKeyFromHref(candidate.getAttribute("href")).startsWith("comment:")
);
if (timestampLink) {
return getCommentKeyFromHref(timestampLink.getAttribute("href"));
}
return "";
}
function getCountableRecords() {
return isCommentsPage() ? getCommentRecords() : getPostRecords();
}
function rememberItemOrder(records) {
records.forEach((record, index) => {
if (orderedKeys.includes(record.key)) {
return;
}
const previousKnownKey = findNearbyKnownKey(records, index - 1, -1);
const nextKnownKey = findNearbyKnownKey(records, index + 1, 1);
if (previousKnownKey) {
orderedKeys.splice(orderedKeys.indexOf(previousKnownKey) + 1, 0, record.key);
} else if (nextKnownKey) {
orderedKeys.splice(orderedKeys.indexOf(nextKnownKey), 0, record.key);
} else {
orderedKeys.push(record.key);
}
});
}
function findNearbyKnownKey(records, startIndex, direction) {
for (
let index = startIndex;
index >= 0 && index < records.length;
index += direction
) {
if (orderedKeys.includes(records[index].key)) {
return records[index].key;
}
}
return "";
}
function applyBrake() {
resetAfterNavigation();
const settleTimeLeft = routeSettlesAt - Date.now();
if (settleTimeLeft > 0) {
scheduleApplyBrake(settleTimeLeft);
return;
}
const records = getCountableRecords();
currentRecordCount = records.length;
rememberItemOrder(records);
if (getKnownRecordCount() < visibleItemLimit) {
unlockBrake();
return;
}
const anchorRecord = findAnchorRecord(records);
if (!anchorRecord) {
unlockBrake();
updateBrakeText();
return;
}
lockAtRecord(anchorRecord, records);
}
function findAnchorRecord(records) {
if (isCommentsPage()) {
return records[visibleItemLimit - 1] || null;
}
const anchorKey = orderedKeys[visibleItemLimit - 1];
if (!anchorKey) {
return null;
}
const fromOrder = records.find((record) => record.key === anchorKey);
if (fromOrder) {
return fromOrder;
}
return records.find((record) => orderedKeys.indexOf(record.key) >= visibleItemLimit) || null;
}
function findFirstHiddenRecord(records) {
if (isCommentsPage()) {
return records[visibleItemLimit] || null;
}
return records.find((record) => orderedKeys.indexOf(record.key) >= visibleItemLimit) || null;
}
function getKnownRecordCount() {
return isCommentsPage() ? currentRecordCount : orderedKeys.length;
}
function lockAtRecord(record, records) {
let nextMaxScrollY = getMaxScrollYForRecord(record, records);
if (minScrollYForNextLock !== null) {
nextMaxScrollY = Math.max(nextMaxScrollY, minScrollYForNextLock);
minScrollYForNextLock = null;
}
setMaxScrollY(nextMaxScrollY);
isLocked = true;
showBrake();
updateBrakeText();
clampScrollToBrake();
pauseMediaAfterLimit();
}
function getMaxScrollYForRecord(record, records) {
if (isCommentsPage()) {
const firstHiddenRecord = findFirstHiddenRecord(records);
if (firstHiddenRecord) {
const rect = firstHiddenRecord.item.getBoundingClientRect();
const hiddenTop = window.scrollY + rect.top;
return Math.max(0, hiddenTop - window.innerHeight + BRAKE_PANEL_HEIGHT);
}
}
const rect = record.item.getBoundingClientRect();
const anchorBottom = window.scrollY + rect.bottom;
return Math.max(0, anchorBottom - window.innerHeight + BRAKE_PANEL_HEIGHT);
}
function setMaxScrollY(nextMaxScrollY) {
if (
!isLocked ||
maxScrollY === null ||
nextMaxScrollY < maxScrollY ||
Date.now() <= allowMaxScrollIncreaseUntil
) {
maxScrollY = nextMaxScrollY;
}
}
function unlockBrake() {
isLocked = false;
maxScrollY = null;
hideBrake();
}
function ensureBrake() {
const existingBrake = document.getElementById(BRAKE_ID);
if (existingBrake) {
return existingBrake;
}
const brake = document.createElement("div");
brake.id = BRAKE_ID;
brake.hidden = true;
brake.innerHTML = `
<div class="reddit-scroll-brake-controls">
<button type="button">Load more</button>
<label>
<span>Items</span>
<input type="number" min="${MIN_ITEMS_PER_CLICK}" max="${MAX_ITEMS_PER_CLICK}" step="1" inputmode="numeric">
</label>
<div class="reddit-scroll-brake-status" aria-live="polite"></div>
</div>
`;
const input = brake.querySelector("input");
const button = brake.querySelector("button");
input.value = String(itemsPerClick);
input.addEventListener("change", syncItemsPerClickFromInput);
input.addEventListener("blur", syncItemsPerClickFromInput);
input.addEventListener("input", updateBrakeText);
button.addEventListener("click", () => {
resetAfterNavigation();
clearScheduledScan();
syncItemsPerClickFromInput();
minScrollYForNextLock = isCommentsPage() ? null : window.scrollY;
allowMaxScrollIncreaseUntil = Date.now() + EXPAND_LOCK_GRACE_MS;
visibleItemLimit += itemsPerClick;
unlockBrake();
scheduleApplyBrake();
});
document.body.appendChild(brake);
return brake;
}
function showBrake() {
ensureBrake().hidden = false;
}
function hideBrake() {
const brake = document.getElementById(BRAKE_ID);
if (brake) {
brake.hidden = true;
}
}
function syncItemsPerClickFromInput() {
const input = document.querySelector(`#${BRAKE_ID} input`);
if (!input) {
return;
}
itemsPerClick = clampWholeNumber(
input.value,
itemsPerClick,
MIN_ITEMS_PER_CLICK,
MAX_ITEMS_PER_CLICK
);
input.value = String(itemsPerClick);
saveItemsPerClick();
updateBrakeText();
}
function updateBrakeText() {
const brake = ensureBrake();
const input = brake.querySelector("input");
const button = brake.querySelector("button");
const status = brake.querySelector(".reddit-scroll-brake-status");
const typedCount = input ? input.value : itemsPerClick;
const previewCount = clampWholeNumber(typedCount, itemsPerClick, MIN_ITEMS_PER_CLICK, MAX_ITEMS_PER_CLICK);
const itemLabel = isCommentsPage() ? "comments" : "posts";
const shownCount = Math.min(visibleItemLimit, getKnownRecordCount());
const buttonText = `Load ${previewCount} more ${itemLabel}`;
if (button && button.textContent !== buttonText) {
button.textContent = buttonText;
}
if (button) {
button.disabled = false;
}
if (input && document.activeElement !== input) {
input.value = String(itemsPerClick);
}
if (status && status.textContent !== `Showing ${shownCount} ${itemLabel}`) {
status.textContent = `Showing ${shownCount} ${itemLabel}`;
}
}
function pauseMediaAfterLimit() {
const records = getCountableRecords();
records.forEach((record) => {
const keyIndex = orderedKeys.indexOf(record.key);
if (keyIndex >= visibleItemLimit) {
pauseMediaInElement(record.item);
}
});
}
function pauseMediaInElement(element) {
element.querySelectorAll("audio, video").forEach((media) => {
media.pause();
media.autoplay = false;
media.removeAttribute("autoplay");
});
}
function scheduleApplyBrake(delay) {
if (scanTimer !== null) {
return;
}
const routeChanged = resetAfterNavigation();
const wait = routeChanged
? ROUTE_SETTLE_DELAY_MS
: clampWholeNumber(delay, SCAN_DELAY_MS, 0, ROUTE_SETTLE_DELAY_MS);
scanTimer = window.setTimeout(() => {
scanTimer = null;
applyBrake();
}, wait);
}
function clearScheduledScan() {
if (scanTimer === null) {
return;
}
window.clearTimeout(scanTimer);
scanTimer = null;
}
function resetAfterNavigation() {
const nextRouteKey = getRouteKey();
if (nextRouteKey === currentRouteKey) {
return false;
}
saveCurrentRouteState();
currentRouteKey = nextRouteKey;
if (!restoreRouteState(nextRouteKey)) {
resetCurrentProgress();
}
return true;
}
function saveCurrentRouteState() {
routeStates.delete(currentRouteKey);
routeStates.set(currentRouteKey, {
visibleItemLimit,
currentRecordCount,
orderedKeys: orderedKeys.slice(),
});
while (routeStates.size > MAX_SAVED_ROUTE_STATES) {
const oldestKey = routeStates.keys().next().value;
routeStates.delete(oldestKey);
}
}
function restoreRouteState(routeKey) {
const state = routeStates.get(routeKey);
if (!state) {
return false;
}
visibleItemLimit = clampWholeNumber(
state.visibleItemLimit,
getInitialVisibleItemLimit(),
1,
Number.MAX_SAFE_INTEGER
);
currentRecordCount = clampWholeNumber(
state.currentRecordCount,
0,
0,
Number.MAX_SAFE_INTEGER
);
orderedKeys.length = 0;
state.orderedKeys.forEach((key) => {
if (key && !orderedKeys.includes(key)) {
orderedKeys.push(key);
}
});
routeSettlesAt = Date.now() + ROUTE_SETTLE_DELAY_MS;
minScrollYForNextLock = null;
allowMaxScrollIncreaseUntil = 0;
clearScheduledScan();
unlockBrake();
return true;
}
function resetCurrentProgress() {
routeStates.delete(currentRouteKey);
visibleItemLimit = getInitialVisibleItemLimit();
routeSettlesAt = Date.now() + ROUTE_SETTLE_DELAY_MS;
minScrollYForNextLock = null;
allowMaxScrollIncreaseUntil = 0;
currentRecordCount = 0;
orderedKeys.length = 0;
clearScheduledScan();
unlockBrake();
}
function clampScrollToBrake() {
const currentScrollY = window.scrollY;
if (resetAfterNavigation()) {
lastScrollY = currentScrollY;
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
return;
}
if (shouldResetAfterTopJump(currentScrollY)) {
resetCurrentProgress();
lastScrollY = currentScrollY;
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
return;
}
if (!isLocked || maxScrollY === null || window.scrollY <= maxScrollY) {
lastScrollY = window.scrollY;
return;
}
window.scrollTo({
top: maxScrollY,
left: window.scrollX,
behavior: "auto",
});
lastScrollY = maxScrollY;
}
function shouldResetAfterTopJump(currentScrollY) {
return (
isLocked &&
maxScrollY !== null &&
currentScrollY <= TOP_RESET_SCROLL_Y &&
(maxScrollY >= TOP_RESET_MIN_DISTANCE || lastScrollY - currentScrollY >= TOP_RESET_MIN_DISTANCE)
);
}
function preventScrollPastBrake(event) {
if (resetAfterNavigation()) {
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
return;
}
if (!isLocked || maxScrollY === null || isEditableTarget(event.target)) {
return;
}
const deltaY = getScrollDeltaY(event);
if (deltaY <= 0 || window.scrollY + deltaY <= maxScrollY) {
return;
}
event.preventDefault();
if (window.scrollY < maxScrollY) {
window.scrollTo({
top: maxScrollY,
left: window.scrollX,
behavior: "auto",
});
}
}
function isEditableTarget(target) {
if (!(target instanceof Element)) {
return false;
}
return Boolean(target.closest('input, textarea, select, [contenteditable="true"]'));
}
function getScrollDeltaY(event) {
if (event.type === "wheel") {
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
return event.deltaY * 16;
}
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
return event.deltaY * window.innerHeight;
}
return event.deltaY;
}
if (event.type === "touchmove") {
const touch = event.touches[0];
if (!touch || lastTouchY === null) {
return 0;
}
return lastTouchY - touch.clientY;
}
if (event.type !== "keydown") {
return 0;
}
const keyDeltas = {
ArrowDown: 40,
PageDown: window.innerHeight * 0.85,
End: Number.MAX_SAFE_INTEGER,
" ": window.innerHeight * 0.85,
};
return keyDeltas[event.key] || 0;
}
function rememberTouchPosition(event) {
const touch = event.touches[0];
lastTouchY = touch ? touch.clientY : null;
}
function forgetTouchPosition() {
lastTouchY = null;
}
function handleProgressResetClick(event) {
if (!shouldResetProgressForClick(event.target)) {
return;
}
resetCurrentProgress();
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
}
function shouldResetProgressForClick(target) {
if (!(target instanceof Element)) {
return false;
}
if (target.closest(`#${BRAKE_ID}`)) {
return false;
}
if (
target.closest(
'a[href="/"], a[href="/home"], a[href$="/home"], a[href$="/best"], a[href$="/new"]'
)
) {
return true;
}
const control = target.closest('button, [role="button"], a');
if (!control) {
return false;
}
const label = `${control.getAttribute("aria-label") || ""} ${control.textContent || ""}`;
return /\b(home|top|best|new|comments|refresh)\b/i.test(label);
}
function handleTopResetShortcut(event) {
if (isEditableTarget(event.target)) {
return;
}
if (event.key !== "Home" && !((event.metaKey || event.ctrlKey) && event.key === "ArrowUp")) {
return;
}
resetCurrentProgress();
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
}
function installNavigationHooks() {
const notifyNavigation = () => {
if (resetAfterNavigation()) {
scheduleApplyBrake(ROUTE_SETTLE_DELAY_MS);
}
};
["pushState", "replaceState"].forEach((methodName) => {
const originalMethod = history[methodName];
if (typeof originalMethod !== "function") {
return;
}
history[methodName] = function () {
const result = originalMethod.apply(this, arguments);
window.setTimeout(notifyNavigation, 0);
return result;
};
});
window.addEventListener("popstate", notifyNavigation);
}
function boot() {
addStyles();
ensureBrake();
installNavigationHooks();
applyBrake();
const observer = new MutationObserver(() => {
scheduleApplyBrake();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
window.addEventListener("scroll", clampScrollToBrake, { passive: true });
window.addEventListener("click", handleProgressResetClick, {
capture: true,
passive: true,
});
window.addEventListener("wheel", preventScrollPastBrake, {
capture: true,
passive: false,
});
window.addEventListener("keydown", handleTopResetShortcut, {
capture: true,
});
window.addEventListener("keydown", preventScrollPastBrake, {
capture: true,
});
window.addEventListener("touchstart", rememberTouchPosition, {
capture: true,
passive: true,
});
window.addEventListener("touchmove", preventScrollPastBrake, {
capture: true,
passive: false,
});
window.addEventListener("touchmove", rememberTouchPosition, {
capture: true,
passive: true,
});
window.addEventListener("touchend", forgetTouchPosition, {
capture: true,
passive: true,
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
})();