Reddit Scroll Brake

Limit visible posts and comments on Reddit and reveal more on demand.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
  }
})();