From 61d86cf727741b447f5f4ff7097a8f2584e35333 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Wed, 30 Sep 2026 19:52:59 +0200 Subject: [PATCH] ffdsa --- public/s/css/f0ckm.css | 26 ++++++ public/s/js/f0ckm.js | 202 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 221 insertions(+), 7 deletions(-) diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 9d301ed..18f7386 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -14457,6 +14457,32 @@ div.posts>a.thumb.has-notif:focus-visible::after { visibility: visible !important; } +/* Sidebar toggle reflow glide (flipSidebarGrids): tiles move under a still cursor, so hover styling + would ride along with the tile that was hovered. Hover is off until the tiles have landed. */ +div.posts.sidebar-flipping { + pointer-events: none; +} + +div.posts.sidebar-flipping>a:not(.notif-item):not(.has-notif):hover, +div.posts.sidebar-flipping>a:not(.notif-item):not(.has-notif).touch-active { + box-shadow: none !important; +} + +html[theme='light'] div.posts.sidebar-flipping>a:not(.notif-item):not(.has-notif):hover, +html[theme='paper'] div.posts.sidebar-flipping>a:not(.notif-item):not(.has-notif):hover { + border-color: #e2e8f0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05) !important; +} + +div.posts.sidebar-flipping>a.thumb.has-notif:hover { + box-shadow: inset 0 0 0 1000px rgba(255, 0, 0, 0.28) !important; +} + +div.posts.sidebar-flipping>a:hover::after, +div.posts.sidebar-flipping>a.touch-active::after { + visibility: hidden !important; +} + /* Stretch the

to fill the entire thumb so p::after is always centered */ div.posts>a.thumb.has-notif p { position: absolute !important; diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index b29094b..7a821d2 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -17840,6 +17840,180 @@ window.cancelAnimFrame = (function () { return document.querySelectorAll(sel); }; + // Thumbnail grids re-wrap into a different column count when their wrapper changes width, so instead of + // snapping they are FLIP-animated: measure every tile, apply the new width, measure again, then move each + // tile in view from where it was to where it landed (position + size). The first visible tile keeps its + // place on screen so the scroll position doesn't jump when the row count changes. + // Tiles that stay in their lane glide there directly. Tiles that wrap to another row don't cut diagonally + // back across the grid: they keep flowing in the reflow direction like a carousel. A stand-in copy slides + // out past the row's edge while the tile itself slides in from the opposite edge of its new row, both + // with the same offset, so it reads as one continuous stream. + const SIDEBAR_GRID_ITEMS = '.posts > *, .tags-grid > *'; + let sidebarGridFlip = { anims: [], cleanup: [] }; + const clearSidebarGridFlip = () => { + sidebarGridFlip.anims.forEach(a => a.cancel()); + sidebarGridFlip.cleanup.forEach(fn => fn()); + sidebarGridFlip = { anims: [], cleanup: [] }; + }; + const flipSidebarGrids = (gridWrappers, change, duration, easing) => { + const items = []; + gridWrappers.forEach(w => w.querySelectorAll(SIDEBAR_GRID_ITEMS).forEach(el => items.push(el))); + const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (!items.length || reduceMotion || items.length > 5000 || typeof items[0].animate !== 'function') { + clearSidebarGridFlip(); + change(); + return; + } + + // Nearest scrolling ancestor (the index container), else the page itself + let scroller = items[0].parentElement; + while (scroller && scroller !== document.body) { + const oy = getComputedStyle(scroller).overflowY; + if ((oy === 'auto' || oy === 'scroll') && scroller.scrollHeight > scroller.clientHeight) break; + scroller = scroller.parentElement; + } + if (!scroller || scroller === document.body) scroller = document.scrollingElement || document.documentElement; + const isPage = scroller === document.scrollingElement || scroller === document.documentElement; + const sr = isPage ? { top: 0, bottom: window.innerHeight } : scroller.getBoundingClientRect(); + const viewTop = Math.max(0, sr.top); + const viewBottom = Math.min(window.innerHeight, sr.bottom); + const inView = r => r.bottom > viewTop && r.top < viewBottom && r.width > 0; + + // Visual positions: a glide still running (quick re-toggle) continues from where the tiles are now + const before = items.map(el => el.getBoundingClientRect()); + const anchorIdx = before.findIndex(inView); + clearSidebarGridFlip(); + const anchorTop = anchorIdx >= 0 ? items[anchorIdx].getBoundingClientRect().top : 0; + const flip = sidebarGridFlip; + const grids = new Map(); + items.forEach(el => { + const g = el.parentElement; + if (grids.has(g)) return; + grids.set(g, { before: g.getBoundingClientRect(), gap: parseFloat(getComputedStyle(g).rowGap) || 0 }); + // No hover while tiles move under the cursor (see .sidebar-flipping in f0ckm.css) + g.classList.add('sidebar-flipping'); + flip.cleanup.push(() => g.classList.remove('sidebar-flipping')); + }); + + change(); + + // The scroller clips at its own edge, which moves in right away when the grid narrows: tiles still + // gliding through the strip the sidebar hasn't covered yet would look cut off. Stretch its box back + // over that strip (negative margin + matching padding, so the content width stays the new one). + if (!isPage) { + const ext = sr.right - scroller.getBoundingClientRect().right; + if (ext > 0.5) { + const cs = getComputedStyle(scroller); + scroller.style.setProperty('margin-right', (parseFloat(cs.marginRight) - ext) + 'px', 'important'); + scroller.style.setProperty('padding-right', (parseFloat(cs.paddingRight) + ext) + 'px', 'important'); + flip.cleanup.push(() => { + scroller.style.removeProperty('margin-right'); + scroller.style.removeProperty('padding-right'); + }); + } + } + + if (anchorIdx >= 0) { + const shift = items[anchorIdx].getBoundingClientRect().top - anchorTop; + if (Math.abs(shift) > 0.5) scroller.scrollTop += shift; + } + + grids.forEach((info, g) => { + info.after = g.getBoundingClientRect(); + const dw = info.after.width - info.before.width; + // Narrower grid: fewer columns, tiles flow forward (wrap off the right, in from the left); wider: backward + info.dir = dw < -1 ? 1 : dw > 1 ? -1 : 0; + }); + + const layerFor = (g, info) => { + if (info.layer) return info.layer; + // Stand-ins live in a fixed layer clipped to the grid's left edge and the scroller's visible band + const layer = document.createElement('div'); + layer.setAttribute('aria-hidden', 'true'); + const left = Math.max(0, info.after.left); + layer.style.cssText = `position:fixed;left:${left}px;top:${viewTop}px;width:${Math.max(0, window.innerWidth - left)}px;height:${Math.max(0, viewBottom - viewTop)}px;overflow:hidden;pointer-events:none;`; + g.after(layer); + info.layer = layer; + flip.cleanup.push(() => layer.remove()); + if (info.dir === 1) { + // Tiles slide in from beyond the left edge: clip them there (right side stays open for the sidebar) + g.style.setProperty('clip-path', 'inset(-100vh -100vw -100vh 0)'); + flip.cleanup.push(() => g.style.removeProperty('clip-path')); + } + return layer; + }; + + const after = items.map(el => el.getBoundingClientRect()); + items.forEach((el, i) => { + const b = before[i]; + const a = after[i]; + if (!a.width || !b.width || (!inView(b) && !inView(a))) return; + const s = b.width / a.width; + const g = el.parentElement; + const info = grids.get(g); + const dir = info.dir; + const wraps = dir !== 0 && g.classList.contains('posts') + && (dir === 1 ? a.left < b.left - a.width / 2 : a.left > b.left + a.width / 2); + + if (!wraps) { + const dx = b.left - a.left; + const dy = b.top - a.top; + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5 && Math.abs(s - 1) < 0.005) return; + flip.anims.push(el.animate( + [ + { transformOrigin: '0 0', transform: `translate(${dx}px, ${dy}px) scale(${s})` }, + { transformOrigin: '0 0', transform: 'none' } + ], + { duration, easing } + )); + return; + } + + // Carousel wrap. L is chosen so the copy clears one edge exactly as the tile emerges from the other. + const gl = info.after.left; + const gr = info.after.right; + const pitchOld = b.height + info.gap; + const pitchNew = a.height + info.gap; + const L = Math.max(a.width, dir === 1 + ? (gr - b.left) + (a.right - gl) + info.gap + : (b.right - gl) + (gr - a.left) + info.gap); + + // The tile itself: enters its new row from the far side, starting one lane past its old row + flip.anims.push(el.animate( + [ + { transformOrigin: '0 0', transform: `translate(${-dir * L}px, ${b.top + dir * pitchOld - a.top}px) scale(${s})` }, + { transformOrigin: '0 0', transform: 'none' } + ], + { duration, easing } + )); + + // The stand-in: leaves its old row over the near edge, drifting with that lane + const layer = layerFor(g, info); + const cs = getComputedStyle(el); + const ghost = document.createElement('div'); + const lr = layer.getBoundingClientRect(); + ghost.style.cssText = `position:absolute;left:${b.left - lr.left}px;top:${b.top - lr.top}px;width:${b.width}px;height:${b.height}px;transform-origin:0 0;`; + ghost.style.backgroundColor = cs.backgroundColor; + ghost.style.backgroundImage = cs.backgroundImage; + ghost.style.backgroundSize = cs.backgroundSize; + ghost.style.backgroundPosition = cs.backgroundPosition; + ghost.style.backgroundRepeat = 'no-repeat'; + layer.appendChild(ghost); + flip.anims.push(ghost.animate( + [ + { transform: 'none' }, + { transform: `translate(${dir * L}px, ${a.top - dir * pitchNew - b.top}px) scale(${1 / s})` } + ], + { duration, easing, fill: 'forwards' } + )); + }); + + setTimeout(() => { + if (sidebarGridFlip !== flip) return; + clearSidebarGridFlip(); + }, duration + 50); + }; + // Smoothly toggle sidebar open/closed window.toggleSidebarRight = () => { const sidebar = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right'); @@ -17854,9 +18028,9 @@ window.cancelAnimFrame = (function () { // Smoothness: the sidebar, #bg canvas, drag pill, right-anchored bars, the Onara modal and the item // view's grid all move together, frame by frame, with one slow curtain-like easing (same feel as the - // legacy comment slide). Only the thumbnail-grid wrappers (index pages) still change width ONCE - // (before the slide when opening, after it when closing): their thumbnails would re-wrap into - // different rows mid-slide, so that single reflow is masked with an opacity fade instead. + // legacy comment slide). Thumbnail grids (index pages) change width once at the start and their tiles + // FLIP-glide into the re-wrapped layout over the same duration. Other padding-based wrappers still + // change width ONCE (before the slide when opening, after it when closing), masked with an opacity fade. const DURATION = 600; const EASING = 'cubic-bezier(0.45, 0, 0.2, 1)'; const glide = DURATION + 'ms ' + EASING; @@ -17881,19 +18055,33 @@ window.cancelAnimFrame = (function () { }); }; - // Padding-based wrappers (thumbnail grids): one-time reflow, softened + const setWrapperPadding = (w, open) => { + w.style.setProperty('transition', 'none', 'important'); + w.style.setProperty('padding-right', open ? sidebarWidth + 'px' : '0px', 'important'); + }; + + // Wrappers holding a thumbnail grid change width right away (both directions) and their tiles glide + // into the new layout together with the sidebar (see flipSidebarGrids) + const gridWrappers = animateWrappers + ? Array.from(wrappers).filter(w => !isEdgeWrapper(w) && w.querySelector(SIDEBAR_GRID_ITEMS)) + : []; + + // Other padding-based wrappers: one-time reflow, softened const applyLayout = (open) => { // Let overlays that mirror the content area (page modal) resize at exactly the same moment window.dispatchEvent(new CustomEvent('f0ck:sidebar-right-layout', { detail: { open, width: sidebarWidth, overlay: !animateWrappers } })); if (!animateWrappers) return; wrappers.forEach(w => { - if (isEdgeWrapper(w)) return; - w.style.setProperty('transition', 'none', 'important'); - w.style.setProperty('padding-right', open ? sidebarWidth + 'px' : '0px', 'important'); + if (isEdgeWrapper(w) || gridWrappers.includes(w)) return; + setWrapperPadding(w, open); if (typeof w.animate === 'function') w.animate([{ opacity: 0.55 }, { opacity: 1 }], { duration: 220, easing: 'ease-out' }); }); }; + if (gridWrappers.length) { + flipSidebarGrids(gridWrappers, () => gridWrappers.forEach(w => setWrapperPadding(w, isHidden)), DURATION, EASING); + } + // Item view: animate the grid's sidebar track so media and comments glide with the sidebar instead // of jumping when the class flips at the end. The target comes from the current track sizes // (last track = sidebar column, the one before it = flexible content column) rather than from