diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index e021a3c..53e8891 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -23600,3 +23600,25 @@ div.posts > .thumb-magnet.is-returning { transform 0.5s cubic-bezier(0.22, 1.45, 0.36, 1), opacity 0.15s ease; } + +/* ── Laser cursor (laser-cursor.js) ── + Canvas above everything that never intercepts the mouse. While it is on, the native cursor is hidden + on every element and pseudo-element, so it never shows; the laser draws its own version of whatever + cursor the element would have (pointer, text, resize, ...). Each :not(#\#) counts as one ID, so the + triple :not() outranks every `cursor: ... !important` rule on the site, including ID-based ones + (Onara's `body.onara-modal-open:not(...) #bg.fader-in { cursor: pointer !important }` beat a + single one: the hand showed in the free space around the item). + [data-laser-probe] is set for an instant by the script to read the cursor an element would show. */ +#laser-cursor-canvas { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 2147483647; +} + +html.laser-cursor-on:not(#\#):not(#\#):not(#\#):not([data-laser-probe]), +html.laser-cursor-on :not(#\#):not(#\#):not(#\#):not([data-laser-probe]), +html.laser-cursor-on :not(#\#):not(#\#):not(#\#):not([data-laser-probe])::before, +html.laser-cursor-on :not(#\#):not(#\#):not(#\#):not([data-laser-probe])::after { + cursor: none !important; +} diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 864c5d6..510fcbc 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -6915,6 +6915,9 @@ window.cancelAnimFrame = (function () { + @@ -7069,6 +7072,26 @@ window.cancelAnimFrame = (function () { + + + `; // ── Sub-Tab Switching Logic ───────────────────────────────────────────── @@ -7095,7 +7118,7 @@ window.cancelAnimFrame = (function () { }); const savedSubtab = localStorage.getItem('f0ck_tuner_active_subtab'); - if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background' || savedSubtab === 'flashyank')) { + if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background' || savedSubtab === 'flashyank' || savedSubtab === 'laser')) { switchSubtab(savedSubtab); } @@ -7139,6 +7162,111 @@ window.cancelAnimFrame = (function () { render(); })(); + // ── Laser cursor pane (window.f0ckLaser from laser-cursor.js) ────────── + // laser-cursor.js loads after this file: rows are built from its schema the first time the API + // exists, and the pane re-reads whenever the tab is opened. + (() => { + const rowsEl = panel.querySelector('#laser-tuner-rows'); + const onBtn = panel.querySelector('#laser-btn-on'); + const offBtn = panel.querySelector('#laser-btn-off'); + const resetBtn = panel.querySelector('#laser-tuner-reset'); + const infoEl = panel.querySelector('#laser-tuner-info'); + if (!rowsEl || !onBtn) return; + const api = () => window.f0ckLaser || null; + const esc = (v) => String(v).replace(/&/g, '&').replace(/"/g, '"').replace(/ { + const n = Number(v); + const shown = (row.step && row.step < 1) ? n.toFixed(row.step < 0.1 ? 2 : 1) : String(Math.round(n)); + return shown + (row.unit || ''); + }; + + const build = () => { + const a = api(); + if (!a || rowsEl.dataset.built) return; + const st = a.getState(); + let html = ''; + a.schema.forEach(row => { + if (row.section) { html += '
' + esc(row.section) + '
'; return; } + const v = st[row.key]; + if (row.type === 'toggle') { + html += '
' + + '
' + esc(row.label) + '
' + + '
' + + '' + + '' + + '
'; + } else if (row.type === 'color') { + html += '
' + + '
' + esc(row.label) + '' + esc(v) + '
' + + '' + + '
'; + } else { + html += '
' + + '
' + esc(row.label) + '' + fmt(row, v) + '
' + + '' + + '
'; + } + }); + rowsEl.innerHTML = html; + rowsEl.dataset.built = '1'; + + rowsEl.querySelectorAll('[data-laser-key]').forEach(rowEl => { + const key = rowEl.dataset.laserKey; + const row = a.schema.find(r => r.key === key); + const valEl = rowEl.querySelector('[data-laser-value]'); + const input = rowEl.querySelector('input'); + if (input) { + input.addEventListener('input', () => { + if (valEl) valEl.textContent = row.type === 'color' ? input.value : fmt(row, input.value); + if (api()) api().set(key, input.value); + }); + } + rowEl.querySelectorAll('[data-laser-toggle]').forEach(b => { + b.addEventListener('click', () => { if (api()) api().set(key, +b.dataset.laserToggle); }); + }); + }); + }; + + const render = (st) => { + const a = api(); + build(); + st = st || (a && a.getState()); + const ready = !!st; + [onBtn, offBtn, resetBtn].forEach(el => { el.disabled = !ready; }); + if (!ready) { infoEl.textContent = 'The laser cursor is not loaded on this page.'; return; } + onBtn.classList.toggle('active', !!st.enabled); + offBtn.classList.toggle('active', !st.enabled); + rowsEl.querySelectorAll('[data-laser-key]').forEach(rowEl => { + const key = rowEl.dataset.laserKey; + const row = a.schema.find(r => r.key === key); + const v = st[key]; + const input = rowEl.querySelector('input'); + const valEl = rowEl.querySelector('[data-laser-value]'); + if (input && document.activeElement !== input) input.value = v; + if (valEl) valEl.textContent = row.type === 'color' ? v : fmt(row, v); + rowEl.querySelectorAll('[data-laser-toggle]').forEach(b => b.classList.toggle('active', +b.dataset.laserToggle === (st[key] ? 1 : 0))); + // Custom colour only matters when the accent is off; rainbow speed only with rainbow on + if (key === 'color') rowEl.style.opacity = st.useAccent ? '0.45' : ''; + if (key === 'rainbowSpeed') rowEl.style.opacity = st.rainbow ? '' : '0.45'; + }); + infoEl.textContent = st.supported + ? 'Changes apply live and are saved on this device.' + : 'Only for mouse/trackpad without "reduce motion": settings are saved but the laser stays off here.'; + }; + + onBtn.addEventListener('click', () => { if (api()) api().setEnabled(true); }); + offBtn.addEventListener('click', () => { if (api()) api().setEnabled(false); }); + resetBtn.addEventListener('click', () => { if (api()) api().reset(); }); + const onChange = (e) => { + if (!panel.isConnected) { window.removeEventListener('f0ck:laser_changed', onChange); return; } + render(e.detail); + }; + window.addEventListener('f0ck:laser_changed', onChange); + const tabBtn = panel.querySelector('.f0ck-tuner-subtab-btn[data-subtab="laser"]'); + if (tabBtn) tabBtn.addEventListener('click', () => render()); + render(); + })(); + // ── Einfach / Erweitert Toggle (Background Tab) ──────────────────────── const bgBasicRows = panel.querySelector('#f0ck-bg-basic-rows'); const bgAdvancedRows = panel.querySelector('#f0ck-bg-advanced-rows'); diff --git a/public/s/js/laser-cursor.js b/public/s/js/laser-cursor.js new file mode 100644 index 0000000..d061442 --- /dev/null +++ b/public/s/js/laser-cursor.js @@ -0,0 +1,552 @@ +/** + * 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