diff --git a/Chaturbate/chaturbate-thumbnails-2x.user.js b/Chaturbate/chaturbate-thumbnails-2x.user.js index e76a1fc..684ea43 100644 --- a/Chaturbate/chaturbate-thumbnails-2x.user.js +++ b/Chaturbate/chaturbate-thumbnails-2x.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Chaturbate 缩略图放大 2 倍 // @namespace https://chaturbate.com/ -// @version 0.11.3 +// @version 0.12.0 // @description 放大当前 Chaturbate 房间列表, 发现页轮播, 关注下拉与悬停预览缩略图 // @match https://chaturbate.com/* // @match https://*.chaturbate.com/* @@ -35,9 +35,9 @@ * CSS 先注入但所有模块默认关闭; 等页面原始 DOM 渲染后, 逐个模块探测原始尺寸, 成功后才打开该模块. * 模块一旦探测成功就锁定, 避免后续 mutation 把已经放大的尺寸当成原始值再次相乘. * - * 4) 探测结果缓存的是"原始尺寸", 不是放大后的尺寸. - * 缓存按模块保存; 命中缓存就直接设置 CSS 变量并启用模块, 未命中才延迟探测. - * 油猴菜单可清除缓存, 清除后刷新页面即可重新探测. + * 4) 不再缓存探测结果. + * Chaturbate 的列表宽度会随页面/窗口变化, 缓存旧尺寸容易污染新布局. + * 当前版本每次页面加载都重新探测; 油猴菜单只用于清除旧版本遗留的 localStorage 缓存. * * 5) 顶部"关注"弹窗的 FOLLOW_DROPDOWN_SHIFT_X 只是位置微调, 不属于缩略图倍率. * @@ -58,11 +58,9 @@ const DEBUG = true; // =========================== - const VER = "0.11.3"; - // 缓存只保存"站点原始尺寸", 不要保存乘过倍率后的尺寸. - // 这样以后只改 THUMBNAIL_SCALE / CARD_HEIGHT_SCALE 时, 可以让缓存自动失效并重新探测, 避免旧倍率污染新布局. - const CACHE_KEY = "tm-thumb-scale:size-cache:v11"; - const CACHE_SCHEMA = 11; + const VER = "0.12.0"; + // 旧版本用过 localStorage 尺寸缓存.当前版本不再读写缓存, 避免旧探测值污染新布局. + const LEGACY_CACHE_PREFIX = "tm-thumb-scale:size-cache:"; const log = (...args) => { if (DEBUG) console.log("[tm-thumb-scale]", ...args); }; @@ -87,9 +85,9 @@ /* * home 模块: * - 适用范围: 主页, 分类页等普通 #roomlist_root 房间列表. - * - 探测值: 卡片原始宽高 + 缩略图原始宽高. - * - 放大方式: grid 列宽按 THUMBNAIL_SCALE 放大一次; 缩略图宽度保持 100%; 缩略图高度按 THUMBNAIL_SCALE 放大. - * - 注意: 关注页列表单独用 followingList 模块, 不和 home 共用缓存. + * - 探测值: 站点 CSS 里的 grid 最小列宽 + 卡片原始高度 + 缩略图原始高度. + * - 放大方式: 原站列数除以 THUMBNAIL_SCALE; 每列仍用 1fr 均分, 保留原站对齐方式. + * - 注意: 关注页列表单独用 followingList 模块, 不和 home 共用探测结果. */ html[data-tm-thumb-scale-home="1"] #main #roomlist_root ul.RoomCardGrid, html[data-tm-thumb-scale-home="1"] #roomlist_root ul.RoomCardGrid, @@ -97,8 +95,10 @@ html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) { /* 改成 grid, 是为了让放大后的卡片按新宽度自然换行. */ display: grid !important; - /* --tm-thumb-home-width 来自当前页面原始卡片宽度, JS 写入时已经乘过倍率. */ - grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-home-width), 1fr)) !important; + /* --tm-thumb-home-columns 来自原站自然列数, JS 写入时已经除过倍率. */ + grid-template-columns: repeat(var(--tm-thumb-home-columns), minmax(0, 1fr)) !important; + justify-content: stretch !important; + align-items: start !important; /* 保留一个稳定间距, 避免放大后卡片互相贴住. */ gap: 0.6em 0.75em !important; } @@ -106,6 +106,7 @@ html[data-tm-thumb-scale-home="1"] #roomlist_root ul.list:has(li.roomCard) li.roomCard { /* 列宽交给 grid 控制, 清掉站点原本写死的 li 宽度限制. */ width: auto !important; + justify-self: stretch !important; min-width: 0 !important; max-width: none !important; height: var(--tm-thumb-home-card-height) !important; @@ -136,23 +137,24 @@ * followingList 模块: * - 适用范围: /followed-cams/ 关注页里的 #roomlist_root 房间列表. * - 为什么和 home 分开: 两者 DOM 选择器很像, 但站点可能给不同页面不同原始列宽. - * - 缓存也单独存 followingList, 避免首页探测值污染关注页. - * - 探测值: 卡片原始宽高 + 缩略图原始宽高. - * - 和 home 一样, grid 列宽按 THUMBNAIL_SCALE 放大一次; 缩略图宽度保持 100%; 缩略图高度按 THUMBNAIL_SCALE 放大. - * - 注意: 这里仍保留 minmax(..., 1fr), 让站点原来的整行拉伸/对齐逻辑继续工作. - * 不能改成固定列宽, 否则宽屏下会出现大空洞, 看起来比原站更乱. + * - followingList 单独探测, 避免首页探测值污染关注页. + * - 探测值: 站点 CSS 里的 grid 最小列宽 + 卡片原始高度 + 缩略图原始高度. + * - 和 home 一样, 原站列数除以 THUMBNAIL_SCALE; 每列仍用 1fr 均分, 保留原站对齐方式. */ html[data-tm-thumb-scale-following-list="1"] #main #roomlist_root ul.RoomCardGrid, html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid, html[data-tm-thumb-scale-following-list="1"] #main #roomlist_root ul.list:has(li.roomCard), html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) { display: grid !important; - grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-following-list-width), 1fr)) !important; + grid-template-columns: repeat(var(--tm-thumb-following-list-columns), minmax(0, 1fr)) !important; + justify-content: stretch !important; + align-items: start !important; gap: 0.6em 0.75em !important; } html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.RoomCardGrid > li.RoomCard, html[data-tm-thumb-scale-following-list="1"] #roomlist_root ul.list:has(li.roomCard) li.roomCard { width: auto !important; + justify-self: stretch !important; min-width: 0 !important; max-width: none !important; height: var(--tm-thumb-following-list-card-height) !important; @@ -180,22 +182,27 @@ /* * related 模块: * - 适用范围: 直播间页面底部"更多这样的房间". - * - 探测值: 卡片原始宽高 + 缩略图原始宽高. - * - 放大方式: 卡片和缩略图分别按自己的原始宽高乘倍率, 避免把 card 宽度误当 thumb 宽度. + * - 探测值: 卡片原始宽度 + 卡片原始高度 + 缩略图原始高度. + * - 放大方式: 卡片宽度按 THUMBNAIL_SCALE 固定放大; 缩略图宽度跟随卡片宽度. + * - 为什么不按列数: 这个区域是频道页里的局部推荐列表, 容器宽度和首页/关注页不同, + * 用"列数除倍率"会把卡片拉成容器均分宽度, 导致"更多这样的房间"宽高比例失真. */ html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid, html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard), html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) { display: grid !important; - grid-template-columns: repeat(auto-fill, minmax(var(--tm-thumb-related-width), 1fr)) !important; + grid-template-columns: repeat(auto-fill, var(--tm-thumb-related-width)) !important; + justify-content: start !important; + align-items: start !important; gap: 0.6em 0.75em !important; } html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid > li.RoomCard, html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) li.roomCard, html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) li.roomCard { - width: auto !important; + width: var(--tm-thumb-related-width) !important; + justify-self: start !important; min-width: 0 !important; - max-width: none !important; + max-width: var(--tm-thumb-related-width) !important; /* 卡片整体高度也要放大; 只放大缩略图高度时, 下方用户名/人数栏可能被压住. */ height: var(--tm-thumb-related-card-height) !important; min-height: var(--tm-thumb-related-card-height) !important; @@ -204,8 +211,7 @@ html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.RoomCardGrid .RoomCardThumbnail, html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) .room_thumbnail_container, html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container { - /* --tm-thumb-related-thumb-width 来自原始缩略图宽度, 不直接复用卡片宽度. */ - width: var(--tm-thumb-related-thumb-width) !important; + width: 100% !important; /* --tm-thumb-related-height 来自原始缩略图容器高度. */ height: var(--tm-thumb-related-height) !important; display: block !important; @@ -217,8 +223,7 @@ html[data-tm-thumb-scale-related="1"] #main.roomPage ul.list:has(li.roomCard) .room_thumbnail, html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container img, html[data-tm-thumb-scale-related="1"] .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail { - /* 图片宽度也按原始缩略图宽度放大, 避免卡片边框/间隙被算进图片宽度. */ - width: var(--tm-thumb-related-thumb-width) !important; + width: 100% !important; /* 图片和容器使用同一个高度, 避免图片溢出或下方信息栏错位. */ height: var(--tm-thumb-related-height) !important; display: block !important; @@ -249,7 +254,9 @@ html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__rooms { display: grid !important; /* 当前设计保持两列, 列宽来自原始卡片宽度 * 倍率. */ - grid-template-columns: repeat(2, minmax(var(--tm-thumb-follow-card-width), 1fr)) !important; + grid-template-columns: repeat(2, var(--tm-thumb-follow-card-width)) !important; + justify-content: start !important; + align-items: start !important; grid-gap: 0.6em 0.75em !important; } html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__room { @@ -266,7 +273,7 @@ display: block !important; } html[data-tm-thumb-scale-follow="1"] .FollowedDropdown__room-image { - width: var(--tm-thumb-follow-thumb-width) !important; + width: 100% !important; height: var(--tm-thumb-follow-thumb-height) !important; display: block !important; max-width: none !important; @@ -380,7 +387,7 @@ let lastReport = null; // 最近一次尺寸探测结果, 控制台里排错主要看这个. let lastMeasure = null; - // 模块锁: 某模块一旦成功应用缓存或探测值, 就不再重新探测. + // 模块锁: 某模块一旦成功应用探测值, 就不再重新探测. const moduleReady = { home: false, followingList: false, @@ -403,6 +410,10 @@ const nonThumbHeight = Math.max(0, cardHeight - thumbHeight); return `${Math.round((thumbHeight * THUMBNAIL_SCALE) + nonThumbHeight)}px`; }; + const scaledCardHeightFromThumbWithScalePx = (cardHeight, thumbHeight, scale) => { + const nonThumbHeight = Math.max(0, cardHeight - thumbHeight); + return `${Math.round((thumbHeight * scale) + nonThumbHeight)}px`; + }; const rootStyle = () => document.documentElement && document.documentElement.style; const isFollowingListPage = () => { try { @@ -432,6 +443,37 @@ style.setProperty(name, scaledCardHeightFromThumbPx(cardHeight, thumbHeight)); return true; }; + const setListScaleVars = (columnsName, cardHeightName, thumbHeightName, metrics, cardWidth, cardHeight, thumbHeight) => { + const style = rootStyle(); + if ( + !style + || !metrics + || !Number.isFinite(metrics.columns) + || !Number.isFinite(cardWidth) + || !Number.isFinite(cardHeight) + || !Number.isFinite(thumbHeight) + || metrics.columns <= 0 + || cardWidth <= 0 + || cardHeight <= 0 + || thumbHeight <= 0 + ) return false; + const scaledColumns = Math.max(1, Math.floor(metrics.columns / THUMBNAIL_SCALE)); + const targetCardWidth = ( + Number.isFinite(metrics.width) + && metrics.width > 0 + && Number.isFinite(metrics.columnGap) + && metrics.columnGap >= 0 + ) + ? (metrics.width - (metrics.columnGap * Math.max(0, scaledColumns - 1))) / scaledColumns + : null; + const effectiveScale = targetCardWidth && targetCardWidth > 0 + ? targetCardWidth / cardWidth + : THUMBNAIL_SCALE; + style.setProperty(columnsName, String(scaledColumns)); + style.setProperty(cardHeightName, scaledCardHeightFromThumbWithScalePx(cardHeight, thumbHeight, effectiveScale)); + style.setProperty(thumbHeightName, `${Math.round(thumbHeight * effectiveScale)}px`); + return true; + }; // 每个模块的 CSS 都由 html[data-tm-thumb-scale-模块名="1"] 控制. // 未探测到原始值时不设置开关, 对应模块完全不放大, 避免用错误尺寸猜页面. const setModuleReady = (name, ready) => { @@ -443,66 +485,21 @@ } catch (_) {} }; const isRect = (value) => ( - // 缓存读取时做最低限度校验, 防止坏数据进入 CSS 变量. value && Number.isFinite(value.width) && Number.isFinite(value.height) && value.width > 0 && value.height > 0 ); - const cloneRect = (value) => (isRect(value) ? { - // 缓存不需要亚像素无限精度, 保留两位小数足够复现布局. - width: Math.round(value.width * 100) / 100, - height: Math.round(value.height * 100) / 100, - } : null); - const readCache = () => { - try { - // localStorage 可能被浏览器策略禁用, 所以所有缓存操作都要 try/catch. - if (typeof localStorage === "undefined") return null; - // 缓存格式错误, 倍率不同, 结构版本不同, 都视为无缓存. - const parsed = JSON.parse(localStorage.getItem(CACHE_KEY) || "null"); - if ( - !parsed - || parsed.schema !== CACHE_SCHEMA - || parsed.scale !== THUMBNAIL_SCALE - || parsed.cardHeightScale !== CARD_HEIGHT_SCALE - || !parsed.modules - ) return null; - return parsed; - } catch (_) { - return null; - } - }; - const writeCache = (cache) => { - try { - // 写入失败不影响脚本运行, 只是下次还会重新探测. - if (typeof localStorage === "undefined") return false; - localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - return true; - } catch (_) { - return false; - } - }; - const updateCacheModule = (name, data) => { - // 模块级缓存: 发现页, 首页, 关注下拉等互不影响. - // 只更新当前模块, 保留其它模块已经探测好的原始尺寸. - const cache = readCache() || { - schema: CACHE_SCHEMA, - scale: THUMBNAIL_SCALE, - cardHeightScale: CARD_HEIGHT_SCALE, - updatedAt: 0, - modules: {}, - }; - cache.updatedAt = Date.now(); - cache.modules[name] = data; - writeCache(cache); - }; const clearCache = () => { try { - // 油猴菜单调用这里; 清完后不强制刷新, 由用户自己刷新页面重新探测. - if (typeof localStorage !== "undefined") localStorage.removeItem(CACHE_KEY); + if (typeof localStorage !== "undefined") { + Object.keys(localStorage) + .filter((key) => key.indexOf(LEGACY_CACHE_PREFIX) === 0) + .forEach((key) => localStorage.removeItem(key)); + } } catch (_) {} - log("cache cleared; refresh page to detect sizes again"); + log("legacy cache cleared; current version always detects sizes fresh"); }; const registerMenu = () => { try { @@ -512,90 +509,6 @@ } } catch (_) {} }; - const applyCachedModule = (name, data) => { - if (!data || moduleReady[name]) return false; - - // 缓存命中时直接写 CSS 变量并打开模块开关. - // 这里仍然通过 setVar() 乘倍率, 因此缓存中始终保持原始尺寸. - if (name === "home" && !isFollowingListPage() && isRect(data.homeCard) && isRect(data.homeThumb)) { - moduleReady.home = [ - setVar("--tm-thumb-home-width", data.homeCard.width), - setCardHeightFromThumbVar("--tm-thumb-home-card-height", data.homeCard.height, data.homeThumb.height), - setVar("--tm-thumb-home-thumb-height", data.homeThumb.height), - ].every(Boolean); - } else if (name === "followingList" && isFollowingListPage() && isRect(data.followingListCard) && isRect(data.followingListThumb)) { - moduleReady.followingList = [ - setVar("--tm-thumb-following-list-width", data.followingListCard.width), - setCardHeightFromThumbVar("--tm-thumb-following-list-card-height", data.followingListCard.height, data.followingListThumb.height), - setVar("--tm-thumb-following-list-thumb-height", data.followingListThumb.height), - ].every(Boolean); - } else if (name === "related" && isRect(data.relatedCard) && isRect(data.relatedThumb)) { - const widthReady = setVar("--tm-thumb-related-width", data.relatedCard.width); - const cardHeightReady = setCardHeightVar("--tm-thumb-related-card-height", data.relatedCard.height); - const thumbWidthReady = setVar("--tm-thumb-related-thumb-width", data.relatedThumb.width); - const heightReady = setVar("--tm-thumb-related-height", data.relatedThumb.height); - moduleReady.related = widthReady && cardHeightReady && thumbWidthReady && heightReady; - } else if (name === "follow" && isRect(data.followCard) && isRect(data.followThumb)) { - const followReady = [ - setVar("--tm-thumb-follow-card-width", data.followCard.width), - setCardHeightFromThumbVar("--tm-thumb-follow-card-height", data.followCard.height, data.followThumb.height), - setVar("--tm-thumb-follow-thumb-width", data.followThumb.width), - setVar("--tm-thumb-follow-thumb-height", data.followThumb.height), - ].every(Boolean); - const style = rootStyle(); - if (followReady && style) { - style.setProperty("--tm-thumb-follow-width", `calc(${Math.round(data.followCard.width * THUMBNAIL_SCALE * 2)}px + 2em)`); - } - moduleReady.follow = followReady; - } else if (name === "tooltip" && isRect(data.tooltipThumb)) { - moduleReady.tooltip = [ - setVar("--tm-thumb-tooltip-width", data.tooltipThumb.width), - setVar("--tm-thumb-tooltip-height", data.tooltipThumb.height), - ].every(Boolean); - } else if ( - name === "discover" - && isRect(data.discoverCard) - && isRect(data.discoverThumb) - && isRect(data.discoverTripleUl) - && isRect(data.discoverTripleArrow) - && isRect(data.discoverDoubleUl) - && isRect(data.discoverDoubleArrow) - && isRect(data.discoverSingleUl) - && isRect(data.discoverSingleArrow) - ) { - const ready = [ - setVar("--tm-thumb-discover-width", data.discoverCard.width), - setCardHeightVar("--tm-thumb-discover-card-height", data.discoverCard.height), - setVar("--tm-thumb-discover-thumb-width", data.discoverThumb.width), - setVar("--tm-thumb-discover-height", data.discoverThumb.height), - setCardHeightVar("--tm-thumb-discover-triple-ul", data.discoverTripleUl.height), - setCardHeightVar("--tm-thumb-discover-triple-arrow", data.discoverTripleArrow.height), - setCardHeightVar("--tm-thumb-discover-double-ul", data.discoverDoubleUl.height), - setCardHeightVar("--tm-thumb-discover-double-arrow", data.discoverDoubleArrow.height), - setCardHeightVar("--tm-thumb-discover-single-ul", data.discoverSingleUl.height), - setCardHeightVar("--tm-thumb-discover-single-arrow", data.discoverSingleArrow.height), - ]; - moduleReady.discover = ready.every(Boolean); - } - - setModuleReady(name, moduleReady[name]); - return moduleReady[name]; - }; - const applyCachedSizes = () => { - const cache = readCache(); - if (!cache) return false; - const modules = cache.modules || {}; - const applied = { - home: applyCachedModule("home", modules.home), - followingList: applyCachedModule("followingList", modules.followingList), - related: applyCachedModule("related", modules.related), - follow: applyCachedModule("follow", modules.follow), - tooltip: applyCachedModule("tooltip", modules.tooltip), - discover: applyCachedModule("discover", modules.discover), - }; - if (DEBUG) log("cache applied", applied); - return Object.values(applied).some(Boolean); - }; const rectOf = (el) => { // getBoundingClientRect 能拿到 CSS 布局后的实际渲染尺寸. if (!el || !el.getBoundingClientRect) return null; @@ -618,24 +531,117 @@ } catch (_) {} return null; }; + const firstElement = (selector) => { + try { + if (!document.querySelector) return null; + return document.querySelector(selector); + } catch (_) { + return null; + } + }; + const parseMinmaxPx = (value) => { + if (!value) return null; + const match = String(value).match(/minmax\(\s*([0-9.]+)px\s*,\s*1fr\s*\)/i); + if (!match) return null; + const width = Number.parseFloat(match[1]); + return Number.isFinite(width) && width > 0 ? width : null; + }; + const selectorMatches = (el, selectorText) => { + try { + return !!(el && selectorText && el.matches && el.matches(selectorText)); + } catch (_) { + return false; + } + }; + const ruleGridMinColumn = (el, rules) => { + if (!el || !rules) return null; + for (const rule of rules) { + if (rule && rule.cssRules) { + const nested = ruleGridMinColumn(el, rule.cssRules); + if (nested) return nested; + } + if (!rule || !rule.style || !rule.selectorText) continue; + if (!selectorMatches(el, rule.selectorText)) continue; + const value = rule.style.getPropertyValue("grid-template-columns"); + const width = parseMinmaxPx(value); + if (width) return width; + } + return null; + }; + const gridMinColumnFor = (selector) => { + const el = firstElement(selector); + if (!el || !document.styleSheets) return null; + for (const sheet of Array.from(document.styleSheets)) { + let rules = null; + try { + rules = sheet.cssRules; + } catch (_) { + continue; + } + const width = ruleGridMinColumn(el, rules); + if (width) return width; + } + return null; + }; + const computedGridColumnCount = (selector) => { + const el = firstElement(selector); + if (!el) return null; + try { + const value = getComputedStyle(el).getPropertyValue("grid-template-columns"); + const count = String(value).split(/\s+/).filter(Boolean).length; + return Number.isFinite(count) && count > 0 ? count : null; + } catch (_) { + return null; + } + }; + const naturalGridMetrics = (selector) => { + const minColumn = gridMinColumnFor(selector); + const el = firstElement(selector); + const rect = rectOf(el); + let columnGap = 0; + try { + if (el && typeof getComputedStyle === "function") { + const styles = getComputedStyle(el); + const parsedGap = Number.parseFloat(styles.columnGap || styles.gap || "0"); + columnGap = Number.isFinite(parsedGap) && parsedGap > 0 ? parsedGap : 0; + } + } catch (_) {} + if (Number.isFinite(minColumn) && minColumn > 0 && rect && rect.width > 0) { + return { + columns: Math.max(1, Math.floor((rect.width + columnGap) / (minColumn + columnGap))), + width: rect.width, + columnGap, + }; + } + const columns = computedGridColumnCount(selector); + return Number.isFinite(columns) && columns > 0 ? { + columns, + width: rect && rect.width, + columnGap, + } : null; + }; const detectAndApplySizes = (stage = "detect") => { /* * 探测策略: * * 1) CSS 已经注入, 但各模块默认关闭, 所以页面先按站点原始布局渲染. * 2) 延迟/MutationObserver 触发后, 读取还没 ready 的模块尺寸. - * 3) 某个模块探测成功后, 写入 CSS 变量, 打开模块开关, 写入缓存, 并把 moduleReady 锁住. + * 3) 某个模块探测成功后, 写入 CSS 变量, 打开模块开关, 并把 moduleReady 锁住. * 4) 锁住后的模块后续不再探测, 避免把已放大的尺寸再次当成原始尺寸. */ const followingListPage = isFollowingListPage(); + const homeMetrics = moduleReady.home || followingListPage ? null : naturalGridMetrics("#roomlist_root ul.RoomCardGrid, #roomlist_root ul.list:has(li.roomCard)"); + const followingListMetrics = moduleReady.followingList || !followingListPage ? null : naturalGridMetrics("#roomlist_root ul.RoomCardGrid, #roomlist_root ul.list:has(li.roomCard)"); const measured = { - // 首页普通房间列表: 卡片宽高 + 缩略图宽高四项独立探测. + // 首页普通房间列表: 列宽来自站点 CSS 的 minmax 最小值, 避免读取到 1fr 拉伸后的当前渲染宽度. + homeMetrics, homeCard: moduleReady.home || followingListPage ? null : firstRect("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"), homeThumb: moduleReady.home || followingListPage ? null : firstRect("#roomlist_root .RoomCardThumbnail, #roomlist_root .room_thumbnail_container"), - // 关注页房间列表: 选择器和首页相似, 但独立探测, 独立缓存, 独立 CSS 变量. + // 关注页房间列表: 同样必须读 CSS 最小列宽, 否则会把已拉伸宽度再乘倍率. + followingListMetrics, followingListCard: moduleReady.followingList || !followingListPage ? null : firstRect("#roomlist_root li.RoomCard, #roomlist_root li.roomCard"), followingListThumb: moduleReady.followingList || !followingListPage ? null : firstRect("#roomlist_root .RoomCardThumbnail, #roomlist_root .room_thumbnail_container"), - // 频道页"更多这样的房间": 卡片宽高和缩略图宽高都要探测, 否则放大后行高容易不齐. + // 频道页"更多这样的房间": 使用局部推荐区自己的卡片原始宽度和高度, 不套用首页列数算法. relatedCard: moduleReady.related ? null : firstRect(".BaseRoomContents ul.RoomCardGrid > li.RoomCard, #main.roomPage ul.list:has(li.roomCard) li.roomCard, .BaseRoomContents ul.list:has(li.roomCard) li.roomCard"), relatedThumb: moduleReady.related ? null : firstRect(".BaseRoomContents ul.RoomCardGrid .RoomCardThumbnail, #main.roomPage ul.list:has(li.roomCard) .room_thumbnail_container, .BaseRoomContents ul.list:has(li.roomCard) .room_thumbnail_container"), // 顶部"关注"弹窗: 通常弹出后才有 DOM, 靠 mutation 触发探测. @@ -656,51 +662,41 @@ }; if (!moduleReady.home && !followingListPage) { - // home 依赖 card/thumb 各自宽高; 成功后立刻缓存, 后续普通列表页面可直接套用. - moduleReady.home = [ - setVar("--tm-thumb-home-width", measured.homeCard && measured.homeCard.width), - setCardHeightFromThumbVar("--tm-thumb-home-card-height", measured.homeCard && measured.homeCard.height, measured.homeThumb && measured.homeThumb.height), - setVar("--tm-thumb-home-thumb-height", measured.homeThumb && measured.homeThumb.height), - ].every(Boolean); + // home 依赖当前页面的自然列数和原始高度; 每次加载重新探测. + moduleReady.home = setListScaleVars( + "--tm-thumb-home-columns", + "--tm-thumb-home-card-height", + "--tm-thumb-home-thumb-height", + measured.homeMetrics, + measured.homeCard && measured.homeCard.width, + measured.homeCard && measured.homeCard.height, + measured.homeThumb && measured.homeThumb.height, + ); setModuleReady("home", moduleReady.home); - if (moduleReady.home) { - updateCacheModule("home", { - homeCard: cloneRect(measured.homeCard), - homeThumb: cloneRect(measured.homeThumb), - }); - } } if (!moduleReady.followingList && followingListPage) { - // followingList 单独缓存, 避免关注页拿到首页的原始宽度. - moduleReady.followingList = [ - setVar("--tm-thumb-following-list-width", measured.followingListCard && measured.followingListCard.width), - setCardHeightFromThumbVar("--tm-thumb-following-list-card-height", measured.followingListCard && measured.followingListCard.height, measured.followingListThumb && measured.followingListThumb.height), - setVar("--tm-thumb-following-list-thumb-height", measured.followingListThumb && measured.followingListThumb.height), - ].every(Boolean); + // followingList 单独探测, 避免关注页拿到首页的列数. + moduleReady.followingList = setListScaleVars( + "--tm-thumb-following-list-columns", + "--tm-thumb-following-list-card-height", + "--tm-thumb-following-list-thumb-height", + measured.followingListMetrics, + measured.followingListCard && measured.followingListCard.width, + measured.followingListCard && measured.followingListCard.height, + measured.followingListThumb && measured.followingListThumb.height, + ); setModuleReady("followingList", moduleReady.followingList); - if (moduleReady.followingList) { - updateCacheModule("followingList", { - followingListCard: cloneRect(measured.followingListCard), - followingListThumb: cloneRect(measured.followingListThumb), - }); - } } if (!moduleReady.related) { - // related 同时依赖卡片宽度, 卡片高度, 缩略图宽度和缩略图高度; 四者缺一不可. - const relatedWidthReady = setVar("--tm-thumb-related-width", measured.relatedCard && measured.relatedCard.width); - const relatedCardHeightReady = setCardHeightVar("--tm-thumb-related-card-height", measured.relatedCard && measured.relatedCard.height); - const relatedThumbWidthReady = setVar("--tm-thumb-related-thumb-width", measured.relatedThumb && measured.relatedThumb.width); - const relatedHeightReady = setVar("--tm-thumb-related-height", measured.relatedThumb && measured.relatedThumb.height); - moduleReady.related = relatedWidthReady && relatedCardHeightReady && relatedThumbWidthReady && relatedHeightReady; + // related 同时依赖卡片宽度, 卡片高度和缩略图高度; 三者缺一不可. + moduleReady.related = [ + setVar("--tm-thumb-related-width", measured.relatedCard && measured.relatedCard.width), + setCardHeightFromThumbVar("--tm-thumb-related-card-height", measured.relatedCard && measured.relatedCard.height, measured.relatedThumb && measured.relatedThumb.height), + setVar("--tm-thumb-related-height", measured.relatedThumb && measured.relatedThumb.height), + ].every(Boolean); setModuleReady("related", moduleReady.related); - if (moduleReady.related) { - updateCacheModule("related", { - relatedCard: cloneRect(measured.relatedCard), - relatedThumb: cloneRect(measured.relatedThumb), - }); - } } if (!moduleReady.follow) { @@ -708,7 +704,6 @@ const followReady = [ setVar("--tm-thumb-follow-card-width", measured.followCard && measured.followCard.width), setCardHeightFromThumbVar("--tm-thumb-follow-card-height", measured.followCard && measured.followCard.height, measured.followThumb && measured.followThumb.height), - setVar("--tm-thumb-follow-thumb-width", measured.followThumb && measured.followThumb.width), setVar("--tm-thumb-follow-thumb-height", measured.followThumb && measured.followThumb.height), ].every(Boolean); moduleReady.follow = followReady; @@ -717,12 +712,6 @@ style.setProperty("--tm-thumb-follow-width", `calc(${Math.round(measured.followCard.width * THUMBNAIL_SCALE * 2)}px + 2em)`); } setModuleReady("follow", moduleReady.follow); - if (moduleReady.follow) { - updateCacheModule("follow", { - followCard: cloneRect(measured.followCard), - followThumb: cloneRect(measured.followThumb), - }); - } } if (!moduleReady.tooltip) { @@ -732,9 +721,6 @@ setVar("--tm-thumb-tooltip-height", measured.tooltipThumb && measured.tooltipThumb.height), ].every(Boolean); setModuleReady("tooltip", moduleReady.tooltip); - if (moduleReady.tooltip) { - updateCacheModule("tooltip", { tooltipThumb: cloneRect(measured.tooltipThumb) }); - } } if (!moduleReady.discover) { @@ -754,18 +740,6 @@ && discoverDoubleUlReady && discoverDoubleArrowReady && discoverSingleUlReady && discoverSingleArrowReady; setModuleReady("discover", moduleReady.discover); - if (moduleReady.discover) { - updateCacheModule("discover", { - discoverCard: cloneRect(measured.discoverCard), - discoverThumb: cloneRect(measured.discoverThumb), - discoverTripleUl: cloneRect(measured.discoverTripleUl), - discoverTripleArrow: cloneRect(measured.discoverTripleArrow), - discoverDoubleUl: cloneRect(measured.discoverDoubleUl), - discoverDoubleArrow: cloneRect(measured.discoverDoubleArrow), - discoverSingleUl: cloneRect(measured.discoverSingleUl), - discoverSingleArrow: cloneRect(measured.discoverSingleArrow), - }); - } } lastMeasure = { stage, moduleReady: { ...moduleReady }, measured }; @@ -850,35 +824,22 @@ injectCount, lastInjectMethod, cssLength: css.length, - cache: (() => { - const cache = readCache(); - return cache ? { - schema: cache.schema, - scale: cache.scale, - cardHeightScale: cache.cardHeightScale, - updatedAt: cache.updatedAt, - modules: Object.keys(cache.modules || {}), - } : null; - })(), + cache: "disabled", cssVars: (() => { try { const style = getComputedStyle(document.documentElement); return { - homeWidth: style.getPropertyValue("--tm-thumb-home-width").trim(), + homeColumns: style.getPropertyValue("--tm-thumb-home-columns").trim(), homeCardHeight: style.getPropertyValue("--tm-thumb-home-card-height").trim(), - homeThumbWidth: style.getPropertyValue("--tm-thumb-home-thumb-width").trim(), homeThumbHeight: style.getPropertyValue("--tm-thumb-home-thumb-height").trim(), - followingListWidth: style.getPropertyValue("--tm-thumb-following-list-width").trim(), + followingListColumns: style.getPropertyValue("--tm-thumb-following-list-columns").trim(), followingListCardHeight: style.getPropertyValue("--tm-thumb-following-list-card-height").trim(), - followingListThumbWidth: style.getPropertyValue("--tm-thumb-following-list-thumb-width").trim(), followingListThumbHeight: style.getPropertyValue("--tm-thumb-following-list-thumb-height").trim(), relatedWidth: style.getPropertyValue("--tm-thumb-related-width").trim(), relatedCardHeight: style.getPropertyValue("--tm-thumb-related-card-height").trim(), - relatedThumbWidth: style.getPropertyValue("--tm-thumb-related-thumb-width").trim(), relatedHeight: style.getPropertyValue("--tm-thumb-related-height").trim(), followWidth: style.getPropertyValue("--tm-thumb-follow-width").trim(), followCardHeight: style.getPropertyValue("--tm-thumb-follow-card-height").trim(), - followThumbWidth: style.getPropertyValue("--tm-thumb-follow-thumb-width").trim(), followThumbHeight: style.getPropertyValue("--tm-thumb-follow-thumb-height").trim(), discoverWidth: style.getPropertyValue("--tm-thumb-discover-width").trim(), discoverCardHeight: style.getPropertyValue("--tm-thumb-discover-card-height").trim(), @@ -1017,13 +978,11 @@ }; log("start v" + VER); - // 注册油猴菜单; 用户需要重测布局时, 可从菜单清除 localStorage 缓存. + // 注册油猴菜单; 只用于清除旧版本遗留的 localStorage 缓存. registerMenu(); - // 尽早尝试应用缓存.缓存命中的模块会立即设置 CSS 变量和 data 开关. - applyCachedSizes(); // 暴露调试 API, 方便后续在控制台手动检查. exposeDebugApi(); - // 第一次报告: 记录启动时是否有缓存, CSS 变量是否已经设置. + // 第一次报告: 记录启动时 CSS 变量是否已经设置. reportDiagnostics("start-before-inject"); // 注入 CSS.因为模块默认关闭, 即使 CSS 很早注入, 也不会改变原始布局. @@ -1049,7 +1008,7 @@ } if (typeof setTimeout === "function") { - // 固定时间点探测用于覆盖: 缓存未命中, React 首屏慢, 图片容器稍后才出现等情况. + // 固定时间点探测用于覆盖: React 首屏慢, 图片容器稍后才出现等情况. setTimeout(() => { scheduleMeasure("after-800ms"); reportDiagnostics("after-800ms"); startPageObserver(); }, 800); // 第二次探测给慢一点的页面留余量. setTimeout(() => { scheduleMeasure("after-2000ms"); reportDiagnostics("after-2000ms"); startPageObserver(); }, 2000); diff --git a/tests/followed-dropdown-css.test.js b/tests/followed-dropdown-css.test.js index 62f76be..492f758 100644 --- a/tests/followed-dropdown-css.test.js +++ b/tests/followed-dropdown-css.test.js @@ -86,39 +86,44 @@ assert.match( ); assert.match( capturedCss, - /data-tm-thumb-scale-following-list="1"[\s\S]*--tm-thumb-following-list-width/, - "followed-cams list should be separated from homepage list sizing", + /data-tm-thumb-scale-following-list="1"[\s\S]*--tm-thumb-following-list-columns/, + "followed-cams list should be separated from homepage list column count", ); assert.match( capturedCss, - /data-tm-thumb-scale-following-list="1"[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(var\(--tm-thumb-following-list-width\),\s*1fr\)\)\s*!important/, - "followed-cams list should keep the site's stretching grid alignment while using detected minimum width", + /data-tm-thumb-scale-following-list="1"[\s\S]*grid-template-columns:\s*repeat\(var\(--tm-thumb-following-list-columns\),\s*minmax\(0,\s*1fr\)\)\s*!important/, + "followed-cams list should reduce the original column count and keep 1fr alignment", ); assert.match( capturedCss, - /#main\.roomPage\s+ul\.list:has\(li\.roomCard\)\s+\.room_thumbnail_container[\s\S]*width:\s*var\(--tm-thumb-related-thumb-width\)\s*!important[\s\S]*height:\s*var\(--tm-thumb-related-height\)\s*!important/, - "room page related rooms thumbnail containers should use detected thumb width and height", + /#main\.roomPage\s+ul\.list:has\(li\.roomCard\)\s+\.room_thumbnail_container[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*var\(--tm-thumb-related-height\)\s*!important/, + "room page related rooms thumbnail containers should follow card width and use detected height", ); assert.match( capturedCss, - /\.BaseRoomContents\s+ul\.list:has\(li\.roomCard\)\s+\.room_thumbnail[\s\S]*width:\s*var\(--tm-thumb-related-thumb-width\)\s*!important[\s\S]*height:\s*var\(--tm-thumb-related-height\)\s*!important/, - "base room related thumbnails should use detected thumb width and height", + /\.BaseRoomContents\s+ul\.list:has\(li\.roomCard\)\s+\.room_thumbnail[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*var\(--tm-thumb-related-height\)\s*!important/, + "base room related thumbnails should follow card width and use detected height", ); assert.match( capturedCss, - /\.BaseRoomContents\s+ul\.RoomCardGrid[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*minmax\(var\(--tm-thumb-related-width\),\s*1fr\)\)\s*!important/, - "base room RoomCardGrid related rooms should use detected width", + /\.BaseRoomContents\s+ul\.RoomCardGrid[\s\S]*grid-template-columns:\s*repeat\(auto-fill,\s*var\(--tm-thumb-related-width\)\)\s*!important/, + "base room RoomCardGrid related rooms should use detected card width", ); assert.match( capturedCss, - /\.BaseRoomContents\s+ul\.RoomCardGrid\s+\.RoomCardThumbnail[\s\S]*width:\s*var\(--tm-thumb-related-thumb-width\)\s*!important[\s\S]*height:\s*var\(--tm-thumb-related-height\)\s*!important/, - "base room RoomCardGrid thumbnails should use detected thumb width and height", + /\.BaseRoomContents\s+ul\.RoomCardGrid\s+\.RoomCardThumbnail[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*var\(--tm-thumb-related-height\)\s*!important/, + "base room RoomCardGrid thumbnails should follow card width and use detected height", ); assert.match( capturedCss, /\.BaseRoomContents\s+ul\.RoomCardGrid\s*>\s*li\.RoomCard[\s\S]*height:\s*var\(--tm-thumb-related-card-height\)\s*!important/, "base room RoomCardGrid related cards should use detected card height", ); +assert.match( + capturedCss, + /\.BaseRoomContents\s+ul\.RoomCardGrid\s*>\s*li\.RoomCard[\s\S]*width:\s*var\(--tm-thumb-related-width\)\s*!important/, + "base room RoomCardGrid related cards should use detected card width", +); assert.doesNotMatch(capturedCss, /--tm-thumb-min-root:\s*\d+px;/, "script should not hard-code a root thumbnail fallback width"); assert.doesNotMatch(capturedCss, /--tm-thumb-height-root:\s*\d+px;/, "script should not hard-code a root thumbnail fallback height"); assert.match( @@ -153,8 +158,8 @@ assert.match( ); assert.match( capturedCss, - /\.FollowedDropdown__room-image[\s\S]*width:\s*var\(--tm-thumb-follow-thumb-width\)\s*!important[\s\S]*height:\s*var\(--tm-thumb-follow-thumb-height\)\s*!important/, - "follow dropdown images should use detected original thumb width and height", + /\.FollowedDropdown__room-image[\s\S]*width:\s*100%\s*!important[\s\S]*height:\s*var\(--tm-thumb-follow-thumb-height\)\s*!important/, + "follow dropdown images should follow fixed card width and use detected height", ); assert.match( capturedCss, @@ -169,11 +174,10 @@ assert.match( assert.doesNotMatch(source, /withOwnStyleDisabled/, "script should not disable its own style while measuring"); assert.match(source, /detectAndApplySizes/, "script should detect original sizes before applying scaled variables"); assert.match(source, /moduleReady/, "script should lock each module after detecting its original size"); -assert.match(source, /CACHE_KEY/, "script should cache detected original sizes"); assert.match(source, /CARD_HEIGHT_SCALE/, "script should use a separate scale for card heights"); assert.match(source, /setCardHeightVar/, "script should apply card height scaling separately from thumbnail scaling"); -assert.match(source, /localStorage\.setItem\(CACHE_KEY/, "script should save detected sizes in localStorage"); -assert.match(source, /localStorage\.removeItem\(CACHE_KEY/, "script should clear cached sizes from the menu command"); +assert.doesNotMatch(source, /localStorage\.setItem/, "script should not write detected sizes to localStorage"); +assert.match(source, /LEGACY_CACHE_PREFIX/, "script should only keep legacy cache cleanup support"); assert.match(source, /scheduleMeasure\("after-800ms"\)/, "script should delay the first page measurement until original layout can render"); assert.doesNotMatch( capturedCss, @@ -226,93 +230,6 @@ assert.equal(earlyAppendedTarget, null, "GM_addStyle should avoid manual documen assert.equal(typeof earlyDomContentLoadedHandler, "function", "script should reinject after head becomes available"); assert.equal(earlyObservedTarget, null, "script should not observe a missing head"); -const styleVars = new Map(); -const attrs = new Map(); -const cacheFakeDocument = { - documentElement: { - style: { - setProperty(name, value) { - styleVars.set(name, value); - }, - }, - setAttribute(name, value) { - attrs.set(name, value); - }, - removeAttribute(name) { - attrs.delete(name); - }, - }, - head: {}, - body: {}, - createElement() { - return { id: "", textContent: "", setAttribute() {} }; - }, - getElementById() { - return null; - }, - querySelectorAll() { - return []; - }, - addEventListener() {}, -}; -const cacheStore = new Map([ - ["tm-thumb-scale:size-cache:v11", JSON.stringify({ - schema: 11, - scale: 2, - cardHeightScale: 1.55, - modules: { - discover: { - discoverCard: { width: 182, height: 176 }, - discoverThumb: { width: 180, height: 101 }, - discoverTripleUl: { width: 1904, height: 550 }, - discoverTripleArrow: { width: 35, height: 534 }, - discoverDoubleUl: { width: 1904, height: 366 }, - discoverDoubleArrow: { width: 35, height: 350 }, - discoverSingleUl: { width: 2127, height: 182 }, - discoverSingleArrow: { width: 35, height: 166 }, - }, - }, - })], -]); - -vm.runInNewContext(source, { - console, - document: cacheFakeDocument, - localStorage: { - getItem(key) { - return cacheStore.get(key) || null; - }, - setItem(key, value) { - cacheStore.set(key, value); - }, - removeItem(key) { - cacheStore.delete(key); - }, - }, - getComputedStyle() { - return { - getPropertyValue(name) { - return styleVars.get(name) || ""; - }, - }; - }, - GM_addStyle() { - return { id: "", textContent: "", setAttribute() {} }; - }, - GM_registerMenuCommand() {}, - MutationObserver: class { - observe() {} - }, -}); - -assert.equal(attrs.get("data-tm-thumb-scale-discover"), "1", "discover should be enabled directly from cached original sizes"); -assert.equal(styleVars.get("--tm-thumb-discover-width"), "364px"); -assert.equal(styleVars.get("--tm-thumb-discover-card-height"), "273px"); -assert.equal(styleVars.get("--tm-thumb-discover-thumb-width"), "360px"); -assert.equal(styleVars.get("--tm-thumb-discover-height"), "202px"); -assert.equal(styleVars.get("--tm-thumb-discover-single-ul"), "282px"); -assert.equal(styleVars.get("--tm-thumb-discover-triple-ul"), "853px"); - const followingVars = new Map(); const followingAttrs = new Map(); const followingFakeDocument = { @@ -350,15 +267,17 @@ vm.runInNewContext(source, { localStorage: { getItem() { return JSON.stringify({ - schema: 11, + schema: 15, scale: 2, cardHeightScale: 1.55, modules: { home: { + homeColumns: 7, homeCard: { width: 174, height: 120 }, homeThumb: { width: 174, height: 98 }, }, followingList: { + followingListColumns: 7, followingListCard: { width: 190, height: 120 }, followingListThumb: { width: 188, height: 106 }, }, @@ -384,10 +303,10 @@ vm.runInNewContext(source, { }, }); -assert.equal(followingAttrs.get("data-tm-thumb-scale-following-list"), "1", "followed-cams should use its own cached list module"); -assert.equal(followingAttrs.get("data-tm-thumb-scale-home"), undefined, "followed-cams should not apply homepage cache"); -assert.equal(followingVars.get("--tm-thumb-following-list-width"), "380px"); -assert.equal(followingVars.get("--tm-thumb-following-list-card-height"), "226px"); +assert.equal(followingAttrs.get("data-tm-thumb-scale-following-list"), undefined, "followed-cams should not apply legacy cached list module"); +assert.equal(followingAttrs.get("data-tm-thumb-scale-home"), undefined, "followed-cams should not apply legacy homepage cache"); +assert.equal(followingVars.get("--tm-thumb-following-list-columns"), undefined); +assert.equal(followingVars.get("--tm-thumb-following-list-card-height"), undefined); assert.equal(followingVars.get("--tm-thumb-following-list-thumb-width"), undefined); -assert.equal(followingVars.get("--tm-thumb-following-list-thumb-height"), "212px"); -assert.equal(followingVars.get("--tm-thumb-home-width"), undefined); +assert.equal(followingVars.get("--tm-thumb-following-list-thumb-height"), undefined); +assert.equal(followingVars.get("--tm-thumb-home-columns"), undefined);