diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 5e72e26..e021a3c 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -21640,7 +21640,8 @@ body.onara-modal-open:not(.onara-modal-closing) .posts > a.thumb:not(.onara-acti filter: none !important; box-shadow: none !important; outline: none !important; - transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important; + transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1), + scale 0.4s cubic-bezier(0.22, 1, 0.36, 1) !important; } body.onara-modal-closing .posts > a.thumb:not(.onara-active) { @@ -21648,7 +21649,9 @@ body.onara-modal-closing .posts > a.thumb:not(.onara-active) { filter: none !important; box-shadow: none !important; outline: none !important; - transition: opacity 0.2s cubic-bezier(0.25, 0, 0.4, 1) !important; + scale: 1; + transition: opacity 0.2s cubic-bezier(0.25, 0, 0.4, 1), + scale 0.3s cubic-bezier(0.25, 0, 0.4, 1) !important; } body.onara-modal-open:not(.onara-modal-closing) .posts > a.thumb:not(.onara-active)::after { @@ -21665,10 +21668,15 @@ body.onara-modal-closing .posts > a.thumb:not(.onara-active)::after { body.onara-modal-open:not(.onara-modal-closing) .posts > a.thumb.onara-active { opacity: 1 !important; filter: none !important; - box-shadow: 0 0 0 3px var(--accent, #9f0), 0 0 20px var(--accent, #9f0), 0 0 35px rgba(255, 255, 255, 0.9) !important; + /* Accent ring + glow, plus a depth shadow underneath for the lifted-out 3D look */ + box-shadow: 0 0 0 3px var(--accent, #9f0), 0 0 20px var(--accent, #9f0), 0 0 35px rgba(255, 255, 255, 0.9), + 0 18px 40px rgba(0, 0, 0, 0.7) !important; z-index: 52 !important; position: relative !important; - transition: box-shadow 0.28s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s ease !important; + /* Pops out toward the viewer (scale is separate from the magnetic hover's translate) */ + scale: 1.1; + transition: box-shadow 0.28s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s ease, + scale 0.45s cubic-bezier(0.22, 1.3, 0.36, 1) !important; } body.onara-modal-open:not(.onara-modal-closing) .posts > a.thumb.onara-active::after { @@ -21682,15 +21690,17 @@ body.onara-modal-closing .posts > a.thumb.onara-active, body.onara-modal-closing .posts > a.thumb.loaded.onara-active { opacity: 1 !important; filter: none !important; - box-shadow: 0 0 0 0 transparent, 0 0 0 0 transparent, 0 0 0 0 transparent !important; + box-shadow: 0 0 0 0 transparent, 0 0 0 0 transparent, 0 0 0 0 transparent, 0 0 0 0 transparent !important; outline-color: transparent !important; border-color: transparent !important; z-index: 50 !important; position: relative !important; + scale: 1; transition: box-shadow 0.2s cubic-bezier(0.25, 0, 0.4, 1), outline-color 0.2s cubic-bezier(0.25, 0, 0.4, 1), border-color 0.2s cubic-bezier(0.25, 0, 0.4, 1), - opacity 0.2s cubic-bezier(0.25, 0, 0.4, 1) !important; + opacity 0.2s cubic-bezier(0.25, 0, 0.4, 1), + scale 0.3s cubic-bezier(0.25, 0, 0.4, 1) !important; } body.onara-modal-closing .posts > a.thumb.onara-active::after { @@ -23511,3 +23521,82 @@ html.album-strip-dragging * { align-items: center; height: 100%; } + +/* Magnetic thumbnail hover (f0ckm.js): one ring per grid glides between thumbnails with a slight spring + overshoot; the hovered thumb leans toward the cursor (--pull-x/--pull-y). Flat 1px ring, no glow. + Transform/translate only, so it stays on the compositor. */ +div.posts > .thumb-magnet { + position: absolute; + left: 0; + top: 0; + width: 0; + height: 0; + z-index: 3; + pointer-events: none; + box-shadow: 0 0 0 1px var(--white); + opacity: 0; + will-change: transform; + transition: + transform 0.32s cubic-bezier(0.22, 1.25, 0.36, 1), + width 0.32s cubic-bezier(0.22, 1.25, 0.36, 1), + height 0.32s cubic-bezier(0.22, 1.25, 0.36, 1), + opacity 0.15s ease; +} + +div.posts > .thumb-magnet.is-on { + opacity: 1; +} + +div.posts > .thumb-magnet.no-glide { + transition: opacity 0.15s ease; +} + +/* The thumbnail the ring is on (mouse or keyboard) drops its own hover/focus/touch-active shadow, so + only the gliding ring shows; a plain touch tap without the ring keeps its own highlight */ +div.posts.has-thumb-magnet > a.magnet-target:not(.notif-item) { + box-shadow: none; +} + +div.posts.has-thumb-magnet > a.thumb:not(.notif-item) { + translate: var(--pull-x, 0px) var(--pull-y, 0px); + transition: box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), + border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), + outline-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), + translate 0.3s cubic-bezier(0.22, 1.25, 0.36, 1); +} + +/* Rubber-band thumbnail drag (f0ckm.js): follows the mouse 1:1 while held (no translate transition), + then springs back on release with the regular translate transition, a bit slower and bouncier */ +div.posts.has-thumb-magnet > a.thumb.thumb-dragging:not(.notif-item), +div.posts > a.thumb.thumb-dragging:not(.notif-item) { + translate: var(--pull-x, 0px) var(--pull-y, 0px); + transition: box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1) !important; + z-index: 4; +} + +div.posts > a.thumb:not(.notif-item):not(.thumb-dragging) { + translate: var(--pull-x, 0px) var(--pull-y, 0px); +} + +div.posts > a.thumb.thumb-returning:not(.notif-item) { + transition: box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), + border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), + outline-color 0.25s cubic-bezier(0.16, 1, 0.3, 1), + translate 0.5s cubic-bezier(0.22, 1.45, 0.36, 1); +} + +div.posts > .thumb-magnet.is-dragging { + transition: opacity 0.15s ease; +} + +html.thumb-drag-active, +html.thumb-drag-active * { + cursor: grabbing !important; + user-select: none !important; +} + +div.posts > .thumb-magnet.is-returning { + transition: + transform 0.5s cubic-bezier(0.22, 1.45, 0.36, 1), + opacity 0.15s ease; +} diff --git a/public/s/css/v0ck.css b/public/s/css/v0ck.css index ac6a6dd..110b4c8 100644 --- a/public/s/css/v0ck.css +++ b/public/s/css/v0ck.css @@ -838,4 +838,23 @@ .v0ck:not(.v0ck_hover), .v0ck:not(.v0ck_hover) * { cursor: none !important; -} \ No newline at end of file +} +/* Volume: pointer over the whole volume area, not only the 5px slider strip, including the slider's + thumb/track parts (Firefox doesn't hand the input's cursor to them). Only while the controls are + shown, so the idle cursor auto-hide above still applies. */ +.v0ck.v0ck_hover .v0ck_volume_group, +.v0ck.v0ck_hover .v0ck_volume_group *, +.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"] { + cursor: pointer !important; +} + +/* Vendor pseudo-elements in separate rules: one unknown pseudo-element drops a whole selector list */ +.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-webkit-slider-thumb, +.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-webkit-slider-runnable-track { + cursor: pointer !important; +} + +.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-moz-range-thumb, +.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-moz-range-track { + cursor: pointer !important; +} diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 9eb85e5..864c5d6 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -11459,7 +11459,10 @@ window.cancelAnimFrame = (function () { window.syncNavbarHeight = () => { const nav = document.querySelector('.navbar'); - if (nav) document.documentElement.style.setProperty('--navbar-h', nav.offsetHeight + 'px'); + // Exact (fractional) height, not offsetHeight: Onara makes the navbar position:fixed and holds the + // page in place with margin-top: var(--navbar-h). A rounded value (78 vs 78.19px) shifted the whole + // page by that fraction on every open/close. + if (nav) document.documentElement.style.setProperty('--navbar-h', nav.getBoundingClientRect().height + 'px'); }; window.syncNavbarHeight(); window.addEventListener('resize', window.syncNavbarHeight); @@ -25193,3 +25196,213 @@ window.BulkSelection = (() => { // Keep it unlocked while it's being used document.addEventListener('input', (e) => { if (e.target === active) armTimer(); }, true); })(); + +// ── Magnetic thumbnail hover ────────────────────────────────────────────────── +// One ring per grid glides from thumbnail to thumbnail (slight spring overshoot) instead of each thumb +// switching its own box-shadow on and off, and the hovered thumbnail leans a few px toward the cursor. +// Only transform/translate animate (compositor only, no layout). Mouse/trackpad devices only. +(() => { + if (!window.matchMedia || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return; + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; + + const THUMB_SEL = 'div.posts > a.thumb:not(.notif-item)'; + const PULL = 3; // max px a thumbnail leans toward the cursor + let current = null; + let rafId = 0; + let lastEvent = null; + + const magnetFor = (posts) => { + let m = posts.querySelector(':scope > .thumb-magnet'); + if (!m) { + if (getComputedStyle(posts).position === 'static') posts.style.position = 'relative'; + m = document.createElement('div'); + m.className = 'thumb-magnet'; + m.setAttribute('aria-hidden', 'true'); + posts.appendChild(m); + posts.classList.add('has-thumb-magnet'); + } + return m; + }; + + const place = (m, thumb, px = 0, py = 0, instant = false) => { + if (instant) m.classList.add('no-glide'); + m.style.width = thumb.offsetWidth + 'px'; + m.style.height = thumb.offsetHeight + 'px'; + m.style.transform = 'translate3d(' + (thumb.offsetLeft + px) + 'px,' + (thumb.offsetTop + py) + 'px,0)'; + if (instant) { void m.offsetWidth; m.classList.remove('no-glide'); } + }; + + const release = (thumb) => { + if (!thumb) return; + thumb.style.removeProperty('--pull-x'); + thumb.style.removeProperty('--pull-y'); + thumb.classList.remove('magnet-target'); + }; + + const hoverTo = (thumb) => { + if (!thumb || thumb === current) return; + const m = magnetFor(thumb.parentElement); + const wasHidden = !m.classList.contains('is-on'); + release(current); + current = thumb; + thumb.classList.add('magnet-target'); + // Coming in from outside the grid: appear on the thumb, glide only between thumbnails + place(m, thumb, 0, 0, wasHidden); + m.classList.add('is-on'); + }; + + document.addEventListener('mouseover', (e) => { + if (drag && drag.moved) return; // the ring stays on the thumbnail being dragged + hoverTo(e.target.closest && e.target.closest(THUMB_SEL)); + }, { passive: true }); + + document.addEventListener('mouseout', (e) => { + if (!current || (drag && drag.moved)) return; + const to = e.relatedTarget; + if (to && to.closest && to.closest(THUMB_SEL)) return; // moving to another thumb: mouseover handles it + if (to && current.contains(to)) return; + const posts = current.parentElement; + const m = posts && posts.querySelector(':scope > .thumb-magnet'); + // Still inside the grid (a gap between thumbs): keep the ring where it is, just drop the lean + if (to && posts && posts.contains(to)) { release(current); current.classList.add('magnet-target'); if (m) place(m, current); return; } + release(current); + if (m) m.classList.remove('is-on'); + current = null; + }, { passive: true }); + + // Heavy rubber-band drag: a grabbed thumbnail follows the mouse with rising resistance (never more + // than DRAG_MAX px) and springs back on release. A real drag doesn't open the item. + const DRAG_MAX = 28; // px the thumb can be pulled at most + const DRAG_SOFT = 90; // px of mouse travel for most of that pull: bigger = heavier + let drag = null; + const resist = (d) => DRAG_MAX * Math.tanh(d / DRAG_SOFT); + + document.addEventListener('mousedown', (e) => { + if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; + const thumb = e.target.closest && e.target.closest(THUMB_SEL); + if (!thumb) return; + drag = { thumb, x0: e.clientX, y0: e.clientY, moved: false }; + }, { passive: true }); + + // Native "drag this link" would take the gesture over + document.addEventListener('dragstart', (e) => { + if (e.target && e.target.closest && e.target.closest(THUMB_SEL)) e.preventDefault(); + }); + + window.addEventListener('mousemove', (e) => { + if (!drag) return; + const dx = e.clientX - drag.x0; + const dy = e.clientY - drag.y0; + if (!drag.moved) { + if (Math.hypot(dx, dy) < 4) return; + drag.moved = true; + drag.thumb.classList.add('thumb-dragging'); + document.documentElement.classList.add('thumb-drag-active'); + } + const px = +resist(dx).toFixed(2); + const py = +resist(dy).toFixed(2); + drag.thumb.style.setProperty('--pull-x', px + 'px'); + drag.thumb.style.setProperty('--pull-y', py + 'px'); + const m = drag.thumb.parentElement && drag.thumb.parentElement.querySelector(':scope > .thumb-magnet'); + if (m && drag.thumb === current) { m.classList.add('is-dragging'); place(m, drag.thumb, px, py); } + }, { passive: true }); + + window.addEventListener('mouseup', (e) => { + if (!drag) return; + const d = drag; + drag = null; + if (!d.moved) return; + d.thumb.classList.remove('thumb-dragging'); + document.documentElement.classList.remove('thumb-drag-active'); + const m = d.thumb.parentElement && d.thumb.parentElement.querySelector(':scope > .thumb-magnet'); + // Spring back (the thumb's translate transition does the glide, slower and bouncier than the lean) + d.thumb.classList.add('thumb-returning'); + clearTimeout(d.thumb._returnTimer); + d.thumb._returnTimer = setTimeout(() => d.thumb.classList.remove('thumb-returning'), 550); + d.thumb.style.setProperty('--pull-x', '0px'); + d.thumb.style.setProperty('--pull-y', '0px'); + if (m) { + m.classList.remove('is-dragging'); + // Ring springs back on exactly the thumb's curve, so they stay together + m.classList.add('is-returning'); + if (d.thumb === current) place(m, d.thumb); + clearTimeout(m._returnTimer); + m._returnTimer = setTimeout(() => m.classList.remove('is-returning'), 550); + } + // It was a drag, not a click: swallow the click that follows this mouseup + const swallow = (ev) => { + if (ev.target && ev.target.closest && ev.target.closest(THUMB_SEL) === d.thumb) { + ev.preventDefault(); + ev.stopImmediatePropagation(); + } + }; + window.addEventListener('click', swallow, { capture: true, once: true }); + setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0); + // Released over another thumbnail (or outside the grid): hand the ring over now + const under = document.elementFromPoint(e.clientX, e.clientY); + const underThumb = under && under.closest && under.closest(THUMB_SEL); + if (underThumb && underThumb !== d.thumb) { + setTimeout(() => hoverTo(underThumb), 0); + } else if (!underThumb && d.thumb === current) { + if (m) m.classList.remove('is-on'); + release(d.thumb); + current = null; + } + }); + + document.addEventListener('mousemove', (e) => { + if (!current || drag) return; + lastEvent = e; + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = 0; + const t = current; + const ev = lastEvent; + if (!t || !ev || !t.isConnected) return; + const r = t.getBoundingClientRect(); + const nx = ((ev.clientX - r.left) / r.width - 0.5) * 2; // -1 .. 1 + const ny = ((ev.clientY - r.top) / r.height - 0.5) * 2; + if (Math.abs(nx) > 1.05 || Math.abs(ny) > 1.05) return; + const px = +(nx * PULL).toFixed(2); + const py = +(ny * PULL).toFixed(2); + t.style.setProperty('--pull-x', px + 'px'); + t.style.setProperty('--pull-y', py + 'px'); + const m = t.parentElement && t.parentElement.querySelector(':scope > .thumb-magnet'); + if (m) place(m, t, px, py); + }); + }, { passive: true }); + + // Keyboard (Tab, then arrow keys, which call .focus() on the thumbs): the ring glides to the focused + // thumbnail the same way it follows the mouse + document.addEventListener('focusin', (e) => { + const thumb = e.target && e.target.closest && e.target.closest(THUMB_SEL); + if (!thumb || thumb === current) return; + let kbd = true; + try { kbd = thumb.matches(':focus-visible'); } catch (_) {} + if (!kbd) return; // mouse click focus: mouseover already placed it + const m = magnetFor(thumb.parentElement); + const wasHidden = !m.classList.contains('is-on'); + release(current); + current = thumb; + thumb.classList.add('magnet-target'); + place(m, thumb, 0, 0, wasHidden); + m.classList.add('is-on'); + }); + + document.addEventListener('focusout', (e) => { + const from = e.target && e.target.closest && e.target.closest(THUMB_SEL); + if (!from || from !== current) return; + const to = e.relatedTarget; + if (to && to.closest && to.closest(THUMB_SEL)) return; // focusin on the next thumb moves it + if (from.matches(':hover')) return; // mouse is still on it: keep the ring + const m = from.parentElement && from.parentElement.querySelector(':scope > .thumb-magnet'); + if (m) m.classList.remove('is-on'); + release(from); + current = null; + }); + + // Grid re-rendered / scrolled away under a resting cursor: forget the old thumb + window.addEventListener('scroll', () => { + if (current && !current.isConnected) current = null; + }, { passive: true, capture: true }); +})();