keyboard-comment-nav

Shared comment-thread keyboard navigation for userscripts.

Acest script nu ar trebui instalat direct. Aceasta este o bibliotecă pentru alte scripturi care este inclusă prin directiva meta a // @require https://update.greasyfork.org/scripts/592124/1907421/keyboard-comment-nav.js

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// Shared comment-thread keyboard navigation for userscripts.
//
// NOT a userscript — no metadata block. Requires keyboard-shortcuts.js
// to be @require'd first:
//
//   // @require https://raw.githubusercontent.com/jshute96/userscripts/main/lib/keyboard-shortcuts.js
//   // @require https://raw.githubusercontent.com/jshute96/userscripts/main/lib/keyboard-comment-nav.js
//
// A site supplies selectors and one tree accessor; this file supplies
// all nine key bindings and the behavior behind them.
//
// The whole tree model is a flat display-ordered list of comments plus
// `parentOf(el)`. Everything else — siblings, thread root, next thread,
// skip-past-subtree — derives from those two. That's what makes the
// keys work on every site regardless of how deep the threading goes:
//
//   * Nested threads (HN, Reddit): all nine keys are distinct.
//   * One level of replies: `r` collapses onto `p`, `n` onto `m`, and
//     `h`/`l` step root-to-root or reply-to-reply within a thread.
//   * Flat comments (no `parentOf` at all): every comment is at depth
//     zero, so `h`/`n`/`m` all become `j`, `l` becomes `k`, and
//     `p`/`r` have nowhere to go.
//
// The duplication in the shallow cases is deliberate. Every key is
// bound on every site and does the most sensible available thing, so
// there's one set of keys to learn rather than one per site.

const CommentNav = (function () {
  'use strict';

  // Minimum pixels of a comment's body that must be visible below any
  // sticky header for it to count as "current". A bare
  // `rect.bottom > 0` test is fooled by the sliver of the previous
  // comment still bleeding behind the header, which makes `j` re-pick
  // it and stall.
  const MIN_VISIBLE_PX = 30;

  // ---------------------------------------------------------------
  // Scroll strategies
  // ---------------------------------------------------------------
  // Sites differ here for real reasons, not by accident, so this stays
  // pluggable:
  //   intoView  — plain scrollIntoView on the window (most sites)
  //   settle    — intoView plus drift correction, for pages that grow
  //               above the target while the animation runs
  //   container — a panel with its own scroll container
  //   raf       — hand-rolled easing, for containers where the native
  //               smooth scroll silently no-ops

  function scrollWindow(el, offset) {
    if (!offset) {
      el.scrollIntoView({ behavior: 'smooth', block: 'start' });
      return;
    }
    // scrollIntoView has no offset option and scroll-margin-top would
    // have to be set per element, so compute it directly.
    const y = el.getBoundingClientRect().top + window.scrollY - offset;
    window.scrollTo({ top: Math.max(0, y), behavior: 'smooth' });
  }

  function scrollContainer(el, offset, container) {
    const top = container.scrollTop
      + (el.getBoundingClientRect().top - container.getBoundingClientRect().top)
      - offset;
    container.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
  }

  // For containers where scrollIntoView and scrollTo both silently
  // no-op (observed on WaPo's fixed drawer with scrollbar-gutter:
  // stable). Direct scrollTop assignment is the only thing that moves
  // them, so the easing is hand-rolled on top of it.
  function scrollRaf(el, offset, container) {
    const target = container.scrollTop
      + (el.getBoundingClientRect().top - container.getBoundingClientRect().top)
      - offset;
    const start = container.scrollTop;
    const delta = Math.max(0, target) - start;
    if (Math.abs(delta) < 1) return;
    const dur = Math.min(350, 120 + Math.abs(delta) * 0.4);
    const t0 = performance.now();
    function step(t) {
      const p = Math.min(1, (t - t0) / dur);
      container.scrollTop = start + delta * (0.5 - 0.5 * Math.cos(Math.PI * p));
      if (p < 1) requestAnimationFrame(step);
    }
    requestAnimationFrame(step);
  }

  // Drift-correcting window scroll.
  //
  // `scrollIntoView` computes its destination offset once, when it's
  // called, and animates to that fixed offset. A page that lazy-loads
  // images and injects ad slots while you read keeps growing *above*
  // the target during the ~1s animation, pushing it further down the
  // document — so we stop short. Jumping to the comments from the top
  // of a long article is the worst case: it crosses the whole page.
  //
  // So after the scroll settles, re-measure and re-issue if the target
  // moved. A couple of corrections is plenty; the page stops growing
  // quickly once it's near the target.
  const DRIFT_TOLERANCE_PX = 4;
  const MAX_CORRECTIONS = 3;
  const SETTLE_TICKS = 3;            // consecutive equal scrollY samples
  const CORRECTION_TIMEOUT_MS = 4000;
  // Chrome takes a frame or two to start a smooth scroll. Without a
  // grace period the "scrollY hasn't moved" test passes before the
  // animation has begun, and we'd spend corrections re-issuing a
  // scroll that was already on its way.
  const SETTLE_GRACE_MS = 250;

  // The browser can't always put the target at the top: the last
  // comments sit within one viewport height of the document end, so
  // the scroll clamps and the target stays part-way down. That's the
  // browser doing all it can, not drift — correcting would re-issue
  // the same clamped scroll and then log a failure for a jump that
  // worked fine.
  function scrollIsClamped() {
    const max = document.documentElement.scrollHeight - window.innerHeight;
    return window.scrollY <= 0 || window.scrollY >= max - 1;
  }

  function makeSettleScroller(tag) {
    let token = 0;
    function abort() { token++; }

    function scroll(el, offset) {
      const mine = ++token;
      scrollWindow(el, offset);

      const started = performance.now();
      const deadline = started + CORRECTION_TIMEOUT_MS;
      let lastY = null;
      let stable = 0;
      let corrections = 0;

      function tick() {
        if (mine !== token) return;    // superseded or aborted
        const y = Math.round(window.scrollY);
        stable = (y === lastY) ? stable + 1 : 0;
        lastY = y;
        if (stable >= SETTLE_TICKS
            && performance.now() - started > SETTLE_GRACE_MS) {
          const top = Math.round(el.getBoundingClientRect().top - offset);
          if (Math.abs(top) <= DRIFT_TOLERANCE_PX) return;   // landed
          if (scrollIsClamped()) return;                     // as close as it gets
          if (corrections >= MAX_CORRECTIONS) {
            console.log(tag, `scroll still ${top}px off after`,
              corrections, 'corrections; giving up');
            return;
          }
          corrections++;
          console.log(tag, `scroll drifted ${top}px, correcting (${corrections})`);
          scrollWindow(el, offset);
          stable = 0;
        }
        if (performance.now() < deadline) {
          requestAnimationFrame(tick);
        } else {
          console.log(tag, 'scroll settle timed out at',
            Math.round(el.getBoundingClientRect().top - offset) + 'px');
        }
      }
      requestAnimationFrame(tick);
    }

    return { scroll, abort };
  }

  // ---------------------------------------------------------------
  // Key table
  // ---------------------------------------------------------------
  // The one place bindings are defined, shared by every site.

  // key, help-screen description, action, log name. Registration
  // order is the order they appear on the `?` help screen, so it runs
  // outward: within a comment, then up, then past.
  const BINDINGS = [
    ['j', 'Go to next comment',                   'next',        'next'],
    ['k', 'Go to previous comment',               'prev',        'prev'],
    ['h', 'Go to next comment at this level',     'siblingNext', 'sibling-next'],
    ['l', 'Go to previous comment at this level', 'siblingPrev', 'sibling-prev'],
    ['p', 'Go to parent comment',                 'parent',      'parent'],
    ['r', 'Go to root comment of this thread',    'root',        'root'],
    ['n', 'Go to next comment at parent level',   'parentNext',  'parent-next'],
    ['m', 'Go to next comment at root level',     'rootNext',    'root-next'],
  ];

  // ---------------------------------------------------------------
  // parentMapper — for sites where per-element parent lookup is O(n)
  // ---------------------------------------------------------------
  // `depthOf` and `rootsOf` call parentOf once per comment, so a
  // parentOf that scans the list on each call makes them quadratic.
  // Sites in that shape (anything resolving the parent by scanning
  // backwards through the comment list) should derive the whole map in
  // one pass instead and wrap it here.
  //
  //   parentOf: CommentNav.parentMapper(all => {
  //     const map = new Map();
  //     … one left-to-right pass …
  //     return map;
  //   })
  //
  // The library builds the comment array fresh on every keypress and
  // then passes that same array everywhere, so keying the cache on it
  // gives exactly one build per keypress and O(1) lookups within it.
  // A WeakMap means the entry dies with the array.
  //
  // Sites whose parent is a DOM ancestor (`closest`) are already O(depth)
  // per call and don't need this.
  function parentMapper(build) {
    const cache = new WeakMap();
    return (el, all) => {
      let map = cache.get(all);
      if (!map) {
        map = build(all);
        cache.set(all, map);
      }
      return map.get(el) || null;
    };
  }

  function create(spec) {
    const tag = spec.tag;
    const enabled = spec.enabled || (() => true);
    const bodyOf = spec.body || (el => el);
    const parentOf = spec.parentOf || (() => null);
    const containerOf = spec.container || (() => null);
    const headerOffset = spec.headerOffset || (() => 0);
    const idOf = spec.id || null;

    const settler = makeSettleScroller(tag);
    const strategy = spec.strategy || 'intoView';

    function scrollToEl(el) {
      const offset = headerOffset();
      const container = containerOf();
      // Both container strategies dereference the container, and a
      // site's container() can legitimately come up empty — NYT walks
      // the drawer looking for a computed overflow-y and gives up
      // after 8 levels, WaPo walks composed ancestors for a scrollable
      // host. Falling back to a window scroll degrades to "probably
      // wrong place" instead of throwing out of the keydown handler,
      // where the failure would be invisible without DevTools open.
      if ((strategy === 'container' || strategy === 'raf') && !container) {
        console.log(tag, 'scroll container not found; using window scroll');
        return scrollWindow(el, offset);
      }
      switch (strategy) {
        case 'settle':    return settler.scroll(el, offset);
        case 'container': return scrollContainer(el, offset, container);
        case 'raf':       return scrollRaf(el, offset, container);
        default:          return scrollWindow(el, offset);
      }
    }

    // -------------------------------------------------------------
    // The comment list
    // -------------------------------------------------------------

    // Re-queried on every keypress, never cached. The visible set can
    // change at any moment — lazy "show replies" expansion, SPA
    // navigation, filter tabs, sort changes, new comments arriving —
    // and re-querying handles all of them without subscribing to any
    // of the site's own events.
    function comments() {
      // Drop comments inside a display:none ancestor (collapsed
      // threads, hidden tab panes). They have zero-area rects, so
      // they never qualify as "current", but `j` from the comment
      // before them would happily pick them as a target and the
      // scroll would resolve to a degenerate position — looking
      // exactly like `j` being stuck.
      return spec.comments().filter(el => el.offsetParent !== null);
    }

    // Best available identity for a non-comment element (the
    // comments-section anchor), for logs.
    function describeEl(el) {
      if (el.id) return `#${el.id}`;
      if (typeof el.className === 'string' && el.className) return `.${el.className.trim().split(/\s+/).join('.')}`;
      return el.tagName.toLowerCase();
    }

    function label(el, all) {
      const i = all.indexOf(el);
      // A site's id() can legitimately come up empty for a given
      // element; fall back to the position rather than logging
      // "undefined".
      const base = (idOf && idOf(el)) || `#${i + 1}`;
      return i >= 0 ? base : `${base}?`;
    }

    // -------------------------------------------------------------
    // Tree derivations — everything below comes from parentOf
    // -------------------------------------------------------------

    function depthOf(el, all) {
      let d = 0;
      let r = el;
      let p;
      while ((p = parentOf(r, all))) { r = p; d++; }
      return d;
    }

    function rootOf(el, all) {
      let r = el;
      let p;
      while ((p = parentOf(r, all))) r = p;
      return r;
    }

    function rootsOf(all) {
      return all.filter(c => !parentOf(c, all));
    }

    // -------------------------------------------------------------
    // Current comment
    // -------------------------------------------------------------

    // With smooth scrolling the viewport hasn't caught up by the time
    // the next keypress fires, so a pure viewport check would re-pick
    // the same source comment, recompute the same target, and look
    // like the script is doing nothing. Remember where we sent the
    // user and treat that as "current" until they move the viewport
    // themselves.
    let lastJumpTarget = null;

    function invalidate() {
      lastJumpTarget = null;
      settler.abort();
    }
    window.addEventListener('wheel', invalidate, { passive: true });
    window.addEventListener('touchmove', invalidate, { passive: true });

    function findCurrent(all) {
      if (lastJumpTarget && all.includes(lastJumpTarget)) return lastJumpTarget;
      const container = containerOf();
      // Compare against the panel's rect when comments live in their
      // own scroll container, and the window otherwise.
      const bounds = container
        ? container.getBoundingClientRect()
        : { top: 0, bottom: window.innerHeight };
      const gate = bounds.top + headerOffset() + MIN_VISIBLE_PX;
      for (const el of all) {
        const rect = bodyOf(el).getBoundingClientRect();
        if (rect.bottom > gate && rect.top < bounds.bottom) return el;
      }
      return null;
    }

    function jumpTo(el, all, how) {
      console.log(tag, `${how} -> ${label(el, all)}`);
      scrollToEl(el);
      lastJumpTarget = el;
    }

    // -------------------------------------------------------------
    // Actions
    // -------------------------------------------------------------
    // Each returns without scrolling (and logs why) when there's
    // nowhere to go. `p` and `r` on a root are the normal case of
    // that: they mean "go up", and there is no up from a root.

    // `list` is either the full comment list or a derived one (the
    // thread roots) that `current` is always a member of, so the
    // not-found branch is defensive only.
    function step(list, current, delta) {
      const i = list.indexOf(current);
      if (i < 0) return null;
      return list[i + delta] || null;
    }

    // The level moves (`h`, `l`, `n`, `m`) all scan display order for
    // the next comment no deeper than `maxDepth`, rather than indexing
    // into a sibling list.
    //
    // Sibling indexing dead-ends: on the last reply of a thread `h`
    // found no next sibling and reported "nowhere to go", even with
    // half the page still below — and `n` did the same whenever the
    // parent was itself a last child. Scanning by depth escalates on
    // its own. Where a sibling exists it *is* the first comment at or
    // above this depth (everything between is a descendant), so the
    // common case is unchanged; where one doesn't, we surface at the
    // nearest ancestor that has one instead of stopping.
    //
    // Nothing is skipped in either direction: forward, the comments
    // passed over are the current subtree; backward, they're the
    // previous sibling's subtree, and from a first child the previous
    // comment at or above its depth is the parent itself.
    function stepLevel(all, current, delta, maxDepth) {
      const start = all.indexOf(current);
      if (start < 0) return null;
      for (let i = start + delta; i >= 0 && i < all.length; i += delta) {
        if (depthOf(all[i], all) <= maxDepth) return all[i];
      }
      return null;
    }

    const ACTIONS = {
      next(all, current) {
        return current ? step(all, current, +1) : all[0];
      },
      prev(all, current) {
        return current ? step(all, current, -1) : null;
      },
      siblingNext(all, current) {
        if (!current) return all[0];
        return stepLevel(all, current, +1, depthOf(current, all));
      },
      siblingPrev(all, current) {
        if (!current) return null;
        return stepLevel(all, current, -1, depthOf(current, all));
      },
      parent(all, current) {
        return current ? parentOf(current, all) : null;
      },
      parentNext(all, current) {
        if (!current) return rootsOf(all)[0];
        // Walk past the parent's whole subtree. From a root there's no
        // parent to skip past, so it degrades to the next root.
        return stepLevel(all, current, +1, Math.max(0, depthOf(current, all) - 1));
      },
      root(all, current) {
        if (!current) return null;
        const r = rootOf(current, all);
        return r === current ? null : r;
      },
      rootNext(all, current) {
        if (!current) return rootsOf(all)[0];
        return stepLevel(all, current, +1, 0);
      },
    };

    // Log lines are `<key>: <action> -> <target>` so they stay
    // greppable and identical in shape across every site.
    // No `enabled()` check here: every binding below carries
    // `when: enabled`, and that placement is load-bearing — a binding
    // whose `when` is false isn't handled at all, so the keystroke
    // passes through to the site rather than being swallowed.
    function run(action, key, how) {
      const all = comments();
      if (!all.length) {
        console.log(tag, `${key}: ${how} — no comments found`);
        return;
      }
      const current = findCurrent(all);
      const target = ACTIONS[action](all, current);
      if (!target) {
        const from = current ? label(current, all) : 'no current comment';
        console.log(tag, `${key}: ${how} — nowhere to go from ${from}`);
        return;
      }
      jumpTo(target, all, `${key}: ${how}`);
    }

    // -------------------------------------------------------------
    // `c` — jump to the comments
    // -------------------------------------------------------------
    // On sites that don't render comments until a button is clicked,
    // `c` opens them; once they're up it scrolls to the top of the
    // section. It reports whether it did anything, so a page with
    // neither comments nor an open button lets the keystroke through
    // to the site.

    function commentsTopTarget() {
      return spec.commentsTop ? spec.commentsTop() : null;
    }

    function canJumpToComments() {
      return !!(commentsTopTarget() || (spec.open && spec.open.canOpen()));
    }

    function jumpToComments() {
      const target = commentsTopTarget();
      if (!target) {
        if (spec.open && spec.open.canOpen()) {
          console.log(tag, 'c: comments not shown yet, opening');
          spec.open.click();
          return;
        }
        console.log(tag, 'c: no comments anchor found');
        return;
      }
      console.log(tag, `c -> ${describeEl(target)}`);
      // Deliberately not stored as lastJumpTarget: `c` means "go to
      // the top of the section", and the next `j` should advance from
      // whatever comment the viewport actually lands on.
      invalidate();
      scrollToEl(target);
    }

    // -------------------------------------------------------------
    // Wire up
    // -------------------------------------------------------------

    const keys = KeyboardShortcuts.create({ tag, capture: !!spec.capture });

    // `c` is registered first so it heads the help screen: it's the
    // one key that's useful before you're in the comments at all.
    keys.register('c', 'Open the comments', jumpToComments,
      { when: canJumpToComments });

    for (const [key, description, action, how] of BINDINGS) {
      keys.register(key, description, () => run(action, key, how),
        { when: enabled });
    }

    // Any other key — PageUp/PageDown, arrows, space, Home/End —
    // means the user is moving the viewport themselves, so the
    // remembered jump target is no longer authoritative.
    keys.onUnhandledKey(invalidate);

    keys.logKeys();

    return { keys, findCurrent, comments, invalidate };
  }

  return { create, parentMapper };
})();