Sticky comments for Hacker News

Make active comments sticky so it's easier to follow the discussion

  1. // ==UserScript==
  2. // @name Sticky comments for Hacker News
  3. // @namespace Violentmonkey Scripts
  4. // @match https://news.ycombinator.com/item*
  5. // @grant none
  6. // @version 1.0
  7. // @author FallenMax
  8. // @description Make active comments sticky so it's easier to follow the discussion
  9. // @license MIT
  10. // ==/UserScript==
  11. "use strict";
  12. (() => {
  13. // src/index.ts
  14. var isDebugging = (localStorage.getItem("debug") ?? "").startsWith("sticky");
  15. var app = {
  16. util: {
  17. dom: {
  18. $$(selector, context = document) {
  19. return Array.from(context.querySelectorAll(selector));
  20. },
  21. $(selector, context = document) {
  22. return context.querySelector(selector) ?? void 0;
  23. }
  24. },
  25. logger: isDebugging ? console.log : () => {
  26. }
  27. },
  28. detect: {
  29. getPageBackgroundColor() {
  30. const $main = app.util.dom.$("#hnmain");
  31. return window.getComputedStyle($main).backgroundColor;
  32. }
  33. },
  34. // measure original position of elements (as if page was not scrolled and no css was applied)
  35. measurements: {
  36. _cache: /* @__PURE__ */ new WeakMap(),
  37. onChange: /* @__PURE__ */ new Set(),
  38. initialize() {
  39. window.addEventListener("resize", () => {
  40. this.invalidate();
  41. });
  42. app.comments.onChange.add(() => {
  43. this.invalidate();
  44. });
  45. },
  46. invalidate() {
  47. this._cache = /* @__PURE__ */ new WeakMap();
  48. this.onChange.forEach((fn) => fn());
  49. app.util.logger("measurements: invalidate");
  50. },
  51. getOriginalRect($tr) {
  52. let cached = this._cache.get($tr);
  53. if (!cached) {
  54. let $measure = $tr.previousElementSibling;
  55. if (!$measure || !$measure.classList.contains("measure")) {
  56. $measure = document.createElement("tr");
  57. $measure.className = "measure";
  58. $tr.parentElement.insertBefore($measure, $tr);
  59. }
  60. let { left, width, height, right } = $tr.getBoundingClientRect();
  61. let { top } = $measure.getBoundingClientRect();
  62. const scrollY = window.scrollY;
  63. top += scrollY;
  64. const bottom = top + height;
  65. cached = { top, left, width, height, right, bottom };
  66. this._cache.set($tr, cached);
  67. }
  68. return cached;
  69. }
  70. },
  71. // comment tree structure
  72. comments: {
  73. _children: /* @__PURE__ */ new WeakMap(),
  74. _roots: [],
  75. onChange: /* @__PURE__ */ new Set(),
  76. initialize() {
  77. this.buildTree();
  78. const $tbody = app.util.dom.$("table.comment-tree > tbody");
  79. const observer = new MutationObserver((mutations) => {
  80. for (const mutation of mutations) {
  81. for (const node of mutation.addedNodes) {
  82. if (node instanceof HTMLElement && node.classList.contains("athing")) {
  83. this.buildTree();
  84. break;
  85. }
  86. }
  87. for (const node of mutation.removedNodes) {
  88. if (node instanceof HTMLElement && node.classList.contains("athing")) {
  89. this.buildTree();
  90. break;
  91. }
  92. }
  93. }
  94. });
  95. observer.observe($tbody, { childList: true });
  96. document.addEventListener(
  97. "click",
  98. (e) => {
  99. const $target = e.target;
  100. if ($target.classList.contains("togg")) {
  101. setTimeout(() => {
  102. this.buildTree();
  103. }, 300);
  104. }
  105. },
  106. { capture: true }
  107. );
  108. },
  109. buildTree() {
  110. app.util.logger("comments: build");
  111. this._roots = [];
  112. this._children = /* @__PURE__ */ new WeakMap();
  113. const $comments = this.getVisibleComments();
  114. let stack = [];
  115. const stackTop = () => stack[stack.length - 1];
  116. const curIndent = () => stack.length - 1;
  117. for (const $comment of $comments) {
  118. const indent = this.getIndent($comment);
  119. while (indent < curIndent() + 1 && curIndent() >= 0) {
  120. stack.pop();
  121. }
  122. console.assert(indent === curIndent() + 1, "indent is not correct");
  123. const top = stackTop();
  124. if (!top) {
  125. this._roots.push($comment);
  126. stack.push($comment);
  127. } else {
  128. const children = this._children.get(top) ?? [];
  129. children.push($comment);
  130. this._children.set(top, children);
  131. stack.push($comment);
  132. }
  133. }
  134. this.onChange.forEach((fn) => fn());
  135. },
  136. // root comment has indent=0
  137. getIndent(tr) {
  138. const $ = app.util.dom.$;
  139. const td = $("td.ind", tr);
  140. console.assert(!!td, "indent element not found");
  141. const indent = Number(td.getAttribute("indent"));
  142. console.assert(!Number.isNaN(indent), "indent is not a number");
  143. return indent;
  144. },
  145. getRootComments() {
  146. return this._roots;
  147. },
  148. getChildren(item) {
  149. return this._children.get(item) ?? [];
  150. },
  151. getVisibleComments() {
  152. const $ = app.util.dom.$;
  153. const $$ = app.util.dom.$$;
  154. const $table = $("table.comment-tree");
  155. console.assert(!!$table, "root element not found");
  156. const $comments = $$("tr.athing.comtr", $table).filter(($tr) => {
  157. if ($tr.classList.contains("noshow")) {
  158. return false;
  159. }
  160. if ($tr.classList.contains("coll")) {
  161. return false;
  162. }
  163. return true;
  164. });
  165. return $comments;
  166. }
  167. },
  168. // sticky calculations
  169. sticky: {
  170. _sticky: /* @__PURE__ */ new Map(),
  171. // top
  172. _stickyList: [],
  173. STICKY_CLASS: "is-sticky",
  174. PUSHED_CLASS: "is-pushed",
  175. getStickyTop(item) {
  176. return this._sticky.get(item);
  177. },
  178. initialize() {
  179. document.head.insertAdjacentHTML(
  180. "beforeend",
  181. `
  182. <style>
  183. tr.comtr.is-sticky {
  184. position: sticky;
  185. box-shadow: rgba(0, 0, 0, 0.15) 0px 0 8px 0px;
  186. background-color: ${app.detect.getPageBackgroundColor()};
  187. }
  188. tr.comtr.is-sticky.is-pushed {
  189. box-shadow: none;
  190. }
  191. </style>`
  192. );
  193. const $table = app.util.dom.$("table.comment-tree");
  194. $table.style.borderCollapse = "collapse";
  195. window.addEventListener(
  196. "scroll",
  197. () => {
  198. this.update();
  199. },
  200. { passive: true }
  201. );
  202. window.addEventListener(
  203. "resize",
  204. () => {
  205. this.update();
  206. },
  207. { passive: true }
  208. );
  209. this.update();
  210. app.comments.onChange.add(() => {
  211. this.update();
  212. });
  213. app.measurements.onChange.add(() => {
  214. this.update();
  215. });
  216. },
  217. update() {
  218. const oldStickyList = this._stickyList;
  219. this._stickyList = [];
  220. this._sticky = /* @__PURE__ */ new Map();
  221. const rootComments = app.comments.getRootComments();
  222. this._stack(rootComments, window.scrollY);
  223. for (const item of oldStickyList) {
  224. if (!this._sticky.has(item)) {
  225. item.classList.remove(this.STICKY_CLASS);
  226. item.classList.remove(this.PUSHED_CLASS);
  227. item.style.top = "";
  228. item.style.zIndex = "";
  229. }
  230. }
  231. },
  232. makeSticky(item, top, isPushed) {
  233. item.style.top = top + "px";
  234. const indent = app.comments.getIndent(item);
  235. item.style.zIndex = String(100 - indent);
  236. item.classList.add(this.STICKY_CLASS);
  237. if (isPushed) {
  238. item.classList.add(this.PUSHED_CLASS);
  239. } else {
  240. item.classList.remove(this.PUSHED_CLASS);
  241. }
  242. this._sticky.set(item, top);
  243. this._stickyList.push(item);
  244. },
  245. _stack(items, scrollY, pusher, holder) {
  246. const ITEM_GAP = 0;
  247. let visibleTop = 0;
  248. if (holder) {
  249. const height = app.measurements.getOriginalRect(holder).height;
  250. const top = app.sticky.getStickyTop(holder);
  251. if (top != null) {
  252. visibleTop = top + height + ITEM_GAP;
  253. }
  254. }
  255. const nextPusherIndex = items.findIndex((item) => {
  256. const rect = app.measurements.getOriginalRect(item);
  257. return rect.top - scrollY > visibleTop;
  258. });
  259. const nextPusher = items[nextPusherIndex] ?? pusher;
  260. const nextHolder = nextPusherIndex === -1 ? items[items.length - 1] : items[nextPusherIndex - 1];
  261. if (nextPusher) {
  262. if (app.measurements.getOriginalRect(nextPusher).top - scrollY < visibleTop) {
  263. return;
  264. }
  265. }
  266. if (nextHolder) {
  267. const r = app.measurements.getOriginalRect(nextHolder);
  268. let top = r.top - scrollY;
  269. top = Math.max(visibleTop, top);
  270. let isPushed = false;
  271. if (nextPusher) {
  272. const nextPusherTop = app.measurements.getOriginalRect(nextPusher).top - scrollY;
  273. if (nextPusherTop - r.height < top) {
  274. top = nextPusherTop - r.height;
  275. isPushed = true;
  276. }
  277. }
  278. if (top !== r.top - scrollY) {
  279. this.makeSticky(nextHolder, top, isPushed);
  280. }
  281. const children = app.comments.getChildren(nextHolder);
  282. if (children.length) {
  283. this._stack(children, scrollY, nextPusher, nextHolder);
  284. }
  285. }
  286. }
  287. },
  288. initialize() {
  289. app.measurements.initialize();
  290. app.comments.initialize();
  291. app.sticky.initialize();
  292. }
  293. };
  294. app.initialize();
  295. if (isDebugging) {
  296. window.app = app;
  297. }
  298. })();