/** * Laser cursor: the mouse pointer becomes a glowing laser dot with a smooth, tapering, fading trail. * - hover over something clickable: a ring opens around the dot * - press: the dot squeezes; release: a shockwave ring * - drag (button held while moving): the beam gets thicker and hotter * - text fields keep the native text cursor; leaving the window or an idle video player fades it out * * One transparent, pointer-events:none canvas over the page, drawn only while something moves * (the rAF loop stops when the trail is gone and all animations have settled). * Mouse/trackpad only; off for touch devices and prefers-reduced-motion. * * Everything is configurable (tuner "Laser" tab): window.f0ckLaser = { getState, set, setEnabled, * reset, defaults, schema }, settings in localStorage 'f0ck_laser_settings', 'f0ck:laser_changed' event. */ (() => { const STORE_KEY = 'f0ck_laser_settings'; const LEGACY_KEY = 'f0ck_laser_cursor'; // old on/off switch const DEFAULTS = { enabled: 1, useAccent: 1, // 1 = theme accent colour, 0 = custom colour below color: '#99ff00', hotCore: 1, // white-hot centre on the beam and dot rainbow: 0, // colour cycles along the trail rainbowSpeed: 90, // degrees per second trailMs: 260, // how long a trail point lives trailPoints: 64, // max points kept (smoothness of long trails) beamWidth: 5, glowWidth: 16, glowStrength: 1, headSize: 3.2, headGlow: 22, hoverRing: 15, rippleSize: 36, rippleMs: 480, dragBoost: 0.7, }; // Tuner rows (the tuner builds its UI from this) const SCHEMA = [ { section: 'Colour' }, { key: 'useAccent', label: 'Use theme accent colour', type: 'toggle' }, { key: 'color', label: 'Custom colour', type: 'color' }, { key: 'hotCore', label: 'White-hot core', type: 'toggle' }, { key: 'rainbow', label: 'Rainbow beam', type: 'toggle' }, { key: 'rainbowSpeed', label: 'Rainbow speed', type: 'range', min: 0, max: 720, step: 10, unit: '°/s' }, { section: 'Trail' }, { key: 'trailMs', label: 'Trail length', type: 'range', min: 0, max: 1500, step: 10, unit: 'ms' }, { key: 'trailPoints', label: 'Trail smoothness', type: 'range', min: 8, max: 240, step: 1, unit: ' pts' }, { key: 'beamWidth', label: 'Beam width', type: 'range', min: 0.5, max: 16, step: 0.5, unit: 'px' }, { key: 'glowWidth', label: 'Glow width', type: 'range', min: 0, max: 60, step: 1, unit: 'px' }, { key: 'glowStrength', label: 'Glow strength', type: 'range', min: 0, max: 3, step: 0.05, unit: 'x' }, { section: 'Dot' }, { key: 'headSize', label: 'Dot size', type: 'range', min: 0, max: 12, step: 0.1, unit: 'px' }, { key: 'headGlow', label: 'Dot glow radius', type: 'range', min: 0, max: 80, step: 1, unit: 'px' }, { key: 'hoverRing', label: 'Hover ring size', type: 'range', min: 0, max: 50, step: 1, unit: 'px' }, { section: 'Click & drag' }, { key: 'rippleSize', label: 'Shockwave size', type: 'range', min: 0, max: 160, step: 1, unit: 'px' }, { key: 'rippleMs', label: 'Shockwave duration', type: 'range', min: 100, max: 2000, step: 10, unit: 'ms' }, { key: 'dragBoost', label: 'Drag boost', type: 'range', min: 0, max: 3, step: 0.05, unit: 'x' }, ]; const CLICKABLE = 'a[href], button, [role="button"], label, select, summary, [onclick], .iconset, .thumb, ' + '.album-thumb-item, .rating-tag.can-cycle, .tag-btn, input[type="range"], input[type="checkbox"], ' + 'input[type="radio"], input[type="button"], input[type="submit"], .v0ck_player_button'; const TEXT_FIELD = 'textarea, [contenteditable=""], [contenteditable="true"], ' + 'input:not([type="range"]):not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="color"]):not([type="file"])'; const mq = (q) => !!(window.matchMedia && window.matchMedia(q).matches); const supported = () => mq('(hover: hover) and (pointer: fine)') && !mq('(prefers-reduced-motion: reduce)'); // ── Settings ──────────────────────────────────────────────────────────────── const S = Object.assign({}, DEFAULTS); try { const saved = JSON.parse(localStorage.getItem(STORE_KEY) || 'null'); if (saved && typeof saved === 'object') Object.keys(DEFAULTS).forEach(k => { if (saved[k] !== undefined) S[k] = saved[k]; }); else if (localStorage.getItem(LEGACY_KEY) === 'false') S.enabled = 0; } catch (_) {} const save = () => { try { localStorage.setItem(STORE_KEY, JSON.stringify(S)); } catch (_) {} }; const coerce = (key, val) => { if (typeof DEFAULTS[key] === 'string') return String(val); const n = Number(val); return isNaN(n) ? DEFAULTS[key] : n; }; // ── State ─────────────────────────────────────────────────────────────────── let canvas = null, ctx = null, W = 0, H = 0; let active = false, rafId = 0; const pts = []; // trail points {x, y, t} const ripples = []; // {x, y, t} let headX = -100, headY = -100, hasPos = false; let visible = 0, visibleTarget = 0; // fade (window leave, text fields, idle player) let ring = 0, ringTarget = 0; // hover ring radius let squeeze = 1, squeezeTarget = 1; // press squeeze let heat = 0, heatTarget = 0; // drag intensity (0..1) let down = false, downX = 0, downY = 0; let lastTarget = null; let hovering = false; // last hover classification said "clickable" let accentRgb = [153, 255, 0]; let customRgb = [153, 255, 0]; let lastColorCheck = 0; const cssColorToRgb = (raw) => { if (!document.body) return null; const probe = document.createElement('span'); probe.style.color = raw; probe.style.display = 'none'; document.body.appendChild(probe); const m = getComputedStyle(probe).color.match(/\d+(\.\d+)?/g); probe.remove(); return (m && m.length >= 3) ? [+m[0], +m[1], +m[2]] : null; }; const readColors = () => { const raw = getComputedStyle(document.body || document.documentElement).getPropertyValue('--accent').trim() || '#9f0'; accentRgb = cssColorToRgb(raw) || accentRgb; customRgb = cssColorToRgb(S.color) || customRgb; }; // Colour at a position along the trail (0 = tail .. 1 = head) const colAt = (a, pos, now) => { if (S.rainbow) { const hue = ((now / 1000) * S.rainbowSpeed + (1 - pos) * 140) % 360; return 'hsla(' + hue.toFixed(1) + ',100%,60%,' + a.toFixed(3) + ')'; } const c = S.useAccent ? accentRgb : customRgb; return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a.toFixed(3) + ')'; }; const core = (a, pos, now) => S.hotCore ? 'rgba(255,255,255,' + a.toFixed(3) + ')' : colAt(a, pos, now); const resize = () => { if (!canvas) return; // 1.5x is plenty for a soft glow and keeps the backing store small (Firefox composites it every frame) const dpr = Math.min(1.5, window.devicePixelRatio || 1); W = window.innerWidth; H = window.innerHeight; prevBox = null; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); kick(); }; // Native cursor: hidden everywhere by CSS (html.laser-cursor-on, see f0ckm.css), so it can never // show, not even for a frame. To still know which cursor an element WOULD show, the element and its // ancestors are briefly marked [data-laser-probe] (the hiding rule skips marked elements), the // computed cursor is read and the marks are removed, all synchronously, so nothing is ever painted. // The laser then draws its own version of that cursor (cursorKind). let nativeOnly = false; // fullscreen on a bare