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