FallenMax / Sticky comments for Hacker News

// ==UserScript==
// @name        Sticky comments for Hacker News
// @namespace   Violentmonkey Scripts
// @match       https://news.ycombinator.com/item*
// @grant       none
// @version     1.0
// @author      FallenMax
// @description Make active comments sticky so it's easier to follow the discussion
// @license     MIT
// ==/UserScript==
"use strict";
(() => {
  // src/index.ts
  var isDebugging = (localStorage.getItem("debug") ?? "").startsWith("sticky");
  var app = {
    util: {
      dom: {
        $$(selector, context = document) {
          return Array.from(context.querySelectorAll(selector));
        },
        $(selector, context = document) {
          return context.querySelector(selector) ?? void 0;
        }
      },
      logger: isDebugging ? console.log : () => {
      }
    },
    detect: {
      getPageBackgroundColor() {
        const $main = app.util.dom.$("#hnmain");
        return window.getComputedStyle($main).backgroundColor;
      }
    },
    // measure original position of elements (as if page was not scrolled and no css was applied)
    measurements: {
      _cache: /* @__PURE__ */ new WeakMap(),
      onChange: /* @__PURE__ */ new Set(),
      initialize() {
        window.addEventListener("resize", () => {
          this.invalidate();
        });
        app.comments.onChange.add(() => {
          this.invalidate();
        });
      },
      invalidate() {
        this._cache = /* @__PURE__ */ new WeakMap();
        this.onChange.forEach((fn) => fn());
        app.util.logger("measurements: invalidate");
      },
      getOriginalRect($tr) {
        let cached = this._cache.get($tr);
        if (!cached) {
          let $measure = $tr.previousElementSibling;
          if (!$measure || !$measure.classList.contains("measure")) {
            $measure = document.createElement("tr");
            $measure.className = "measure";
            $tr.parentElement.insertBefore($measure, $tr);
          }
          let { left, width, height, right } = $tr.getBoundingClientRect();
          let { top } = $measure.getBoundingClientRect();
          const scrollY = window.scrollY;
          top += scrollY;
          const bottom = top + height;
          cached = { top, left, width, height, right, bottom };
          this._cache.set($tr, cached);
        }
        return cached;
      }
    },
    // comment tree structure
    comments: {
      _children: /* @__PURE__ */ new WeakMap(),
      _roots: [],
      onChange: /* @__PURE__ */ new Set(),
      initialize() {
        this.buildTree();
        const $tbody = app.util.dom.$("table.comment-tree > tbody");
        const observer = new MutationObserver((mutations) => {
          for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
              if (node instanceof HTMLElement && node.classList.contains("athing")) {
                this.buildTree();
                break;
              }
            }
            for (const node of mutation.removedNodes) {
              if (node instanceof HTMLElement && node.classList.contains("athing")) {
                this.buildTree();
                break;
              }
            }
          }
        });
        observer.observe($tbody, { childList: true });
        document.addEventListener(
          "click",
          (e) => {
            const $target = e.target;
            if ($target.classList.contains("togg")) {
              setTimeout(() => {
                this.buildTree();
              }, 300);
            }
          },
          { capture: true }
        );
      },
      buildTree() {
        app.util.logger("comments: build");
        this._roots = [];
        this._children = /* @__PURE__ */ new WeakMap();
        const $comments = this.getVisibleComments();
        let stack = [];
        const stackTop = () => stack[stack.length - 1];
        const curIndent = () => stack.length - 1;
        for (const $comment of $comments) {
          const indent = this.getIndent($comment);
          while (indent < curIndent() + 1 && curIndent() >= 0) {
            stack.pop();
          }
          console.assert(indent === curIndent() + 1, "indent is not correct");
          const top = stackTop();
          if (!top) {
            this._roots.push($comment);
            stack.push($comment);
          } else {
            const children = this._children.get(top) ?? [];
            children.push($comment);
            this._children.set(top, children);
            stack.push($comment);
          }
        }
        this.onChange.forEach((fn) => fn());
      },
      // root comment has indent=0
      getIndent(tr) {
        const $ = app.util.dom.$;
        const td = $("td.ind", tr);
        console.assert(!!td, "indent element not found");
        const indent = Number(td.getAttribute("indent"));
        console.assert(!Number.isNaN(indent), "indent is not a number");
        return indent;
      },
      getRootComments() {
        return this._roots;
      },
      getChildren(item) {
        return this._children.get(item) ?? [];
      },
      getVisibleComments() {
        const $ = app.util.dom.$;
        const $$ = app.util.dom.$$;
        const $table = $("table.comment-tree");
        console.assert(!!$table, "root element not found");
        const $comments = $$("tr.athing.comtr", $table).filter(($tr) => {
          if ($tr.classList.contains("noshow")) {
            return false;
          }
          if ($tr.classList.contains("coll")) {
            return false;
          }
          return true;
        });
        return $comments;
      }
    },
    // sticky calculations
    sticky: {
      _sticky: /* @__PURE__ */ new Map(),
      // top
      _stickyList: [],
      STICKY_CLASS: "is-sticky",
      PUSHED_CLASS: "is-pushed",
      getStickyTop(item) {
        return this._sticky.get(item);
      },
      initialize() {
        document.head.insertAdjacentHTML(
          "beforeend",
          `
<style>
tr.comtr.is-sticky {
	position: sticky;
	box-shadow: rgba(0, 0, 0, 0.15) 0px 0 8px 0px;
	background-color: ${app.detect.getPageBackgroundColor()};
}
tr.comtr.is-sticky.is-pushed {
	box-shadow: none;
}
</style>`
        );
        const $table = app.util.dom.$("table.comment-tree");
        $table.style.borderCollapse = "collapse";
        window.addEventListener(
          "scroll",
          () => {
            this.update();
          },
          { passive: true }
        );
        window.addEventListener(
          "resize",
          () => {
            this.update();
          },
          { passive: true }
        );
        this.update();
        app.comments.onChange.add(() => {
          this.update();
        });
        app.measurements.onChange.add(() => {
          this.update();
        });
      },
      update() {
        const oldStickyList = this._stickyList;
        this._stickyList = [];
        this._sticky = /* @__PURE__ */ new Map();
        const rootComments = app.comments.getRootComments();
        this._stack(rootComments, window.scrollY);
        for (const item of oldStickyList) {
          if (!this._sticky.has(item)) {
            item.classList.remove(this.STICKY_CLASS);
            item.classList.remove(this.PUSHED_CLASS);
            item.style.top = "";
            item.style.zIndex = "";
          }
        }
      },
      makeSticky(item, top, isPushed) {
        item.style.top = top + "px";
        const indent = app.comments.getIndent(item);
        item.style.zIndex = String(100 - indent);
        item.classList.add(this.STICKY_CLASS);
        if (isPushed) {
          item.classList.add(this.PUSHED_CLASS);
        } else {
          item.classList.remove(this.PUSHED_CLASS);
        }
        this._sticky.set(item, top);
        this._stickyList.push(item);
      },
      _stack(items, scrollY, pusher, holder) {
        const ITEM_GAP = 0;
        let visibleTop = 0;
        if (holder) {
          const height = app.measurements.getOriginalRect(holder).height;
          const top = app.sticky.getStickyTop(holder);
          if (top != null) {
            visibleTop = top + height + ITEM_GAP;
          }
        }
        const nextPusherIndex = items.findIndex((item) => {
          const rect = app.measurements.getOriginalRect(item);
          return rect.top - scrollY > visibleTop;
        });
        const nextPusher = items[nextPusherIndex] ?? pusher;
        const nextHolder = nextPusherIndex === -1 ? items[items.length - 1] : items[nextPusherIndex - 1];
        if (nextPusher) {
          if (app.measurements.getOriginalRect(nextPusher).top - scrollY < visibleTop) {
            return;
          }
        }
        if (nextHolder) {
          const r = app.measurements.getOriginalRect(nextHolder);
          let top = r.top - scrollY;
          top = Math.max(visibleTop, top);
          let isPushed = false;
          if (nextPusher) {
            const nextPusherTop = app.measurements.getOriginalRect(nextPusher).top - scrollY;
            if (nextPusherTop - r.height < top) {
              top = nextPusherTop - r.height;
              isPushed = true;
            }
          }
          if (top !== r.top - scrollY) {
            this.makeSticky(nextHolder, top, isPushed);
          }
          const children = app.comments.getChildren(nextHolder);
          if (children.length) {
            this._stack(children, scrollY, nextPusher, nextHolder);
          }
        }
      }
    },
    initialize() {
      app.measurements.initialize();
      app.comments.initialize();
      app.sticky.initialize();
    }
  };
  app.initialize();
  if (isDebugging) {
    window.app = app;
  }
})();