summaryrefslogtreecommitdiff
path: root/public/scrollbar.js
blob: b297d76e1d248f4cf64d5f368f913047a5389978 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
function initScrollbox(box) {
  // Guard against double-initialization
  if (box.dataset.initialized) return;
  box.dataset.initialized = "true";

  const area = box.querySelector("[data-scroll-area]");
  const track = box.querySelector("[data-scroll-track]");
  const thumb = box.querySelector("[data-scroll-thumb]");

  const inset = () => parseFloat(getComputedStyle(thumb).left) || 0;

  const update = () => {
    const pad = inset();
    const room = track.clientHeight - pad * 2;
    const maxScroll = area.scrollHeight - area.clientHeight;
    const ratio = Math.min(1, area.clientHeight / area.scrollHeight);
    const height = Math.max(room * ratio, thumb.clientWidth);
    const progress = maxScroll > 0 ? area.scrollTop / maxScroll : 0;
    thumb.style.height = `${height}px`;
    thumb.style.top = `${pad + progress * (room - height)}px`;
  };

  thumb.addEventListener("pointerdown", (e) => {
    e.preventDefault();
    thumb.setPointerCapture(e.pointerId);
    const startY = e.clientY;
    const startTop = parseFloat(thumb.style.top) || 0;

    const move = (ev) => {
      const pad = inset();
      const room = track.clientHeight - pad * 2 - thumb.offsetHeight;
      const top = Math.min(
        Math.max(startTop - pad + ev.clientY - startY, 0),
        room,
      );
      const progress = room > 0 ? top / room : 0;
      area.scrollTop = progress * (area.scrollHeight - area.clientHeight);
    };
    const stop = () => {
      thumb.removeEventListener("pointermove", move);
      thumb.removeEventListener("pointerup", stop);
      thumb.removeEventListener("pointercancel", stop);
    };
    thumb.addEventListener("pointermove", move);
    thumb.addEventListener("pointerup", stop);
    thumb.addEventListener("pointercancel", stop);
  });

  area.addEventListener("scroll", update, { passive: true });
  const observer = new ResizeObserver(update);
  observer.observe(area);
  for (const child of area.children) observer.observe(child);
  update();
}

document.querySelectorAll("[data-scrollbox]").forEach(initScrollbox);

document.addEventListener("htmx:afterSettle", () => {
  document.querySelectorAll("[data-scrollbox]").forEach(initScrollbox);
});

document.addEventListener("keydown", (e) => {
  const tag = document.activeElement?.tagName;
  if (
    ["INPUT", "TEXTAREA", "SELECT"].includes(tag) ||
    document.activeElement?.isContentEditable
  ) {
    return;
  }

  if (e.ctrlKey || e.altKey || e.metaKey) return;
  if (e.key !== "j" && e.key !== "k") return;

  const scrollAmount = 100; // in pixels
  const dir = e.key === "j" ? scrollAmount : -scrollAmount;
  const scrollTarget = document.scrollingElement || document.documentElement;

  if (scrollTarget && scrollTarget.scrollHeight > scrollTarget.clientHeight) {
    scrollTarget.scrollBy({ top: dir, behavior: "auto" });
  } else {
    window.scrollBy({ top: dir, behavior: "auto" });
  }
});