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();
},
};
}
|