File size: 2,982 Bytes
39371ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
const IDLE_DELAY = 7_000;
const TRAIL_DURATION = 6_500;

export function createIdlePaws(element, getArea) {
  const layer = document.createElement('div');
  layer.className = 'terminal-paws'; layer.hidden = true;
  layer.setAttribute('aria-hidden', 'true');
  for (let i = 0; i < 6; i++) layer.append(document.createElement('i'));
  element.append(layer);
  const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)');
  const listeners = new AbortController();
  let enabled = false, visible = false, disposed = false;
  let timer = 0, trailTimer = 0, wakeAt = 0, nextAt = 0;

  function canRun() {
    return enabled && visible && !disposed && !document.hidden && document.hasFocus()
      && !reducedMotion.matches && !document.querySelector('dialog[open]');
  }
  function hide() {
    clearTimeout(trailTimer); trailTimer = 0;
    if (!layer.hidden) layer.hidden = true;
  }
  function schedule() {
    if (!canRun()) { clearTimeout(timer); timer = 0; return; }
    // Reuse the pending timer when repeated input moves the deadline later.
    if (timer && wakeAt <= nextAt) return;
    clearTimeout(timer); wakeAt = nextAt;
    timer = setTimeout(tick, Math.max(0, nextAt - Date.now()));
  }
  function reset() {
    hide(); nextAt = Date.now() + IDLE_DELAY; schedule();
  }
  function tick() {
    timer = 0;
    if (!canRun()) return;
    if (Date.now() < nextAt) { schedule(); return; }
    const area = getArea();
    if (area && area.width >= 220 && area.height >= 180) {
      Object.assign(layer.style, {
        left: `${area.left}px`, top: `${area.top}px`,
        width: `${area.width}px`, height: `${area.height}px`,
      });
      layer.style.setProperty('--trail-shift', `${Math.random() * 10}%`);
      layer.hidden = false;
      trailTimer = setTimeout(hide, TRAIL_DURATION);
    }
    nextAt = Date.now() + TRAIL_DURATION + 45_000 + Math.random() * 15_000;
    schedule();
  }
  const options = { passive: true, capture: true, signal: listeners.signal };
  for (const event of ['pointerdown', 'keydown', 'input', 'wheel', 'scroll', 'touchstart', 'touchmove', 'focusin']) {
    document.addEventListener(event, reset, options);
  }
  document.addEventListener('visibilitychange', reset, options);
  window.addEventListener('blur', reset, options);
  window.addEventListener('focus', reset, options);
  reducedMotion.addEventListener('change', reset, options);
  const dialogs = new MutationObserver(reset);
  for (const dialog of document.querySelectorAll('dialog')) dialogs.observe(dialog, { attributes: true, attributeFilter: ['open'] });
  const visibility = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; reset(); });
  visibility.observe(element);

  return {
    reset,
    setEnabled(value) { if (value !== enabled) { enabled = value; reset(); } },
    dispose() {
      disposed = true; hide(); clearTimeout(timer);
      listeners.abort(); dialogs.disconnect(); visibility.disconnect(); layer.remove();
    },
  };
}