NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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; } })();